<?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: SixSet</title>
    <description>The latest articles on DEV Community by SixSet (@sixset).</description>
    <link>https://dev.to/sixset</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%2F4042257%2Fa684b9c7-01f1-4083-a43f-d31fd34ff933.png</url>
      <title>DEV Community: SixSet</title>
      <link>https://dev.to/sixset</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sixset"/>
    <language>en</language>
    <item>
      <title>Tabs as a layer, not a rewrite: pointers instead of copies, one live form, and persistence split between the browser and the server.</title>
      <dc:creator>SixSet</dc:creator>
      <pubDate>Mon, 03 Aug 2026 19:40:01 +0000</pubDate>
      <link>https://dev.to/sixset/how-we-added-multi-tab-support-to-a-40-field-form-without-rewriting-the-reducer-thin-tabs-5c27</link>
      <guid>https://dev.to/sixset/how-we-added-multi-tab-support-to-a-40-field-form-without-rewriting-the-reducer-thin-tabs-5c27</guid>
      <description>&lt;p&gt;The last few weeks in &lt;a href="https://github.com/devset-io/devset-ce" rel="noopener noreferrer"&gt;devset CE&lt;/a&gt; were about tidying up foundations: a refreshed landing page with a walkthrough, ESLint 10, security bumps. This commit is different — &lt;strong&gt;2,133 new lines&lt;/strong&gt; that bring editor-style request tabs to Message Dispatch — the kind every serious API client has.&lt;/p&gt;

&lt;p&gt;It's the biggest UX change in that module since it was created, so instead of a dry changelog, here are the architectural patterns behind it — and where I deliberately compromised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Message Dispatch was a single form — roughly 40 pieces of state: broker (Kafka/RabbitMQ), topic/exchange, headers, proto schema, payload studio, wire format. Switching to a different request meant losing unsaved edits.&lt;/p&gt;

&lt;p&gt;Debugging a flow that needs several messages — &lt;code&gt;order-created&lt;/code&gt;, a quick &lt;code&gt;payment-failed&lt;/code&gt; test, back to the first one — meant juggling one shared state and a lot of patience. The solution has been known for years: tabs. Each scenario in its own tab, state surviving even a browser close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern #1: Thin tab — a pointer, not a copy
&lt;/h2&gt;

&lt;p&gt;The fundamental design decision: &lt;strong&gt;a tab does not copy request parameters&lt;/strong&gt;. It points at a saved &lt;code&gt;SingleRequest&lt;/code&gt; — the single source of truth — and holds a local form snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DispatchTab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;savedRequestRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SavedRequestRef&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;   &lt;span class="c1"&gt;// null = scratch tab&lt;/span&gt;
  &lt;span class="na"&gt;formSnapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PerTabFields&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;         &lt;span class="c1"&gt;// local, NEVER sent to the server&lt;/span&gt;
  &lt;span class="na"&gt;activeHistoryRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alternative — a tab as a full copy of the request — would force syncing N copies on every collection edit: "verify &amp;amp; refetch" loops, race conditions, permanent staleness. Clients that went the copy route pay for it in exactly this way — "the tab says one thing, the saved request says another" is a well-known class of bug.&lt;/p&gt;

&lt;p&gt;In the pointer model the only possible staleness is a dangling ref — someone deleted the request a tab points to — and we catch that lazily, at hydration time. Zero active synchronization.&lt;/p&gt;

&lt;p&gt;The boundary between "belongs to the tab" and "shared" is drawn by the &lt;code&gt;PerTabFields&lt;/code&gt; type — a &lt;code&gt;Pick&lt;/code&gt; over 29 state fields. The snapshot extractor lists those fields explicitly, so any drift between the type and the extractor is stopped by the compiler, not by code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern #2: Stash + hydrate — one live form
&lt;/h2&gt;

&lt;p&gt;The key optimization: &lt;strong&gt;only the active tab is live&lt;/strong&gt;. The reducer doesn't keep N copies of a forty-field state — on tab switch it stashes the current form into the outgoing tab's snapshot and pours in the incoming one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tabSwitched&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;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tab&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;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="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="nx"&gt;target&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;state&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="nf"&gt;applyPerTabFields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;formSnapshot&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;defaultPerTabFields&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;stashActiveTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;activeTabId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The biggest win here is invisible in the diff: &lt;strong&gt;the existing form logic doesn't know tabs exist&lt;/strong&gt;. Effects, selectors and components work exactly as before — on "that one" state. Tabs are a layer on top of the reducer, not a rewrite of it. The whole mechanism lives in a new &lt;code&gt;MessageDispatch.tabs.ts&lt;/code&gt; — 170 lines of pure functions, tested without rendering anything.&lt;/p&gt;

&lt;p&gt;Edge cases are closed too: closing the active tab activates a neighbour, closing the last one leaves a fresh scratch tab, and opening a saved request from the list always creates a new tab at the front of the bar — no dedup, because every click is a separate mirror. Want to compare two variants of the same request side by side? Click twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern #3: Persistence behind a repository seam
&lt;/h2&gt;

&lt;p&gt;All persistence hides behind a single interface — &lt;code&gt;load()&lt;/code&gt; and &lt;code&gt;save(workspace)&lt;/code&gt;. The reducer and the components see that contract and never learn where the data physically lives.&lt;/p&gt;

&lt;p&gt;The localStorage implementation gets &lt;code&gt;Storage&lt;/code&gt; injected, so it's tested with an in-memory map, no DOM required; a throwing or full storage degrades to "no persistence" instead of blowing up the app. Writes are debounced (400 ms) so we don't hammer the disk on every keystroke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern #4: Hybrid — composing two repositories
&lt;/h2&gt;

&lt;p&gt;The most interesting piece. There are two persistence layers, with different roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;localStorage&lt;/strong&gt; holds the full content of all tabs — scratch tabs and edited mirrors of saved requests. This is what rescues unsaved edits across a browser reload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;backend&lt;/strong&gt; (&lt;code&gt;GET/PUT /dispatch-workspace&lt;/code&gt;) receives only pointers to saved tabs: id, collection, request name, title, order. The server knows &lt;em&gt;what&lt;/em&gt; is open — never &lt;em&gt;what's inside it&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A third implementation of the same interface glues them together — a classic Composite over a shared port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workspace&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;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                      &lt;span class="c1"&gt;// full content — rescues edits&lt;/span&gt;
    &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;activeTabId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;savedOnly&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="c1"&gt;// pointers — cross-device&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;On load, a merge happens with one iron rule: &lt;strong&gt;local content wins per id&lt;/strong&gt;, while tabs known only to the server — opened on another device — are appended and hydrated fresh from their saved requests. In-flight local edits and the active tab stay untouched. When the API is down, everything degrades to plain localStorage and the user notices nothing.&lt;/p&gt;

&lt;p&gt;That rule is the result of a painful lesson. The first iteration split things as "scratch tabs to localStorage, saved tabs to the server" — sounded logical, and lost mirror edits on reload, because an edited mirror is no longer what sits on the server. Hence the current rule: local holds everything, the server holds only the open set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern #5: Backend — workspace blob, last-write-wins
&lt;/h2&gt;

&lt;p&gt;The new &lt;code&gt;io.devset.ce.be.dispatchworkspace&lt;/code&gt; module is a full hexagon in the style of the neighbouring &lt;code&gt;singlerequest&lt;/code&gt;: a logic-free controller, a facade, a pure domain on records, MapStruct at layer boundaries.&lt;/p&gt;

&lt;p&gt;The endpoint treats the workspace as a singleton per instance with last-write-wins semantics — devset CE is single-user and local, so per-tab diffs or CRDTs would be engineering for show. And if it's ever needed: migrating up from a blob is easy; the other direction is much harder.&lt;/p&gt;

&lt;p&gt;The tab list lands in SQLite as a single JSON column via a null-safe JPA converter — deliberately without a separate table and relations. Tabs are always read and written as a whole, so normalization would only buy us JOINs and migrations, with no upside.&lt;/p&gt;

&lt;h2&gt;
  
  
  UX details
&lt;/h2&gt;

&lt;p&gt;A few decisions that define how the feature feels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Update&lt;/strong&gt; saves to the request's source collection — the tab remembers where it came from — and asks explicitly in a dedicated modal before overwriting.&lt;/li&gt;
&lt;li&gt;Saving without a collection lands in an auto-created &lt;em&gt;"Uncategorized"&lt;/em&gt;, because forcing a collection name during a quick "I'll save this for later" was pure friction.&lt;/li&gt;
&lt;li&gt;Tab colors went through several iterations, because the first version was "too neon" — the active tab ended up white and lifted by a shadow, with the broker marked by a neutral chip with a dot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test grid: pure tab functions and the reducer, both repositories plus the hybrid, components, and on the backend side the domain, the converter and a controller integration test. Plus 250 lines of E2E — including surviving a browser reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known trade-offs — because honesty only gets cheaper after the fact
&lt;/h2&gt;

&lt;p&gt;No architecture is free, so let's write down what we're paying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The stash invariant is not enforced by the compiler: every future action that changes the active tab has to remember &lt;code&gt;stashActiveTab()&lt;/code&gt;. Plan: a guard test enumerating actions that touch &lt;code&gt;activeTabId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A new form field has to be added to &lt;code&gt;PerTabFields&lt;/code&gt; by hand — the compiler won't hint that it should be per-tab. Forget it → the field leaks between tabs.&lt;/li&gt;
&lt;li&gt;The append-only merge doesn't propagate closes: a tab closed on device B can come back from device A's localStorage. For a single user — acceptable, but it's a conscious anomaly, not an oversight.&lt;/li&gt;
&lt;li&gt;A dirty guard on tab close — deferred, because there's no reliable "dirty" signal yet. Today it's the only path to real edit loss, so it's high on the list, not "someday".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;For the user: no lost work, parallel scenarios without juggling, continuity across devices. Architecturally: tabs landed without rewriting the existing form — and that's the best proof that stash + hydrate was the right call.&lt;/p&gt;

&lt;p&gt;As always — &lt;strong&gt;devset CE is source available&lt;/strong&gt;, feedback welcome.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Playwright + Docker: How I Set Up E2E Gating for Devset CE (and What Bit Me)</title>
      <dc:creator>SixSet</dc:creator>
      <pubDate>Mon, 27 Jul 2026 09:56:37 +0000</pubDate>
      <link>https://dev.to/sixset/playwright-docker-how-i-set-up-e2e-gating-for-devset-ce-and-what-bit-me-3oj</link>
      <guid>https://dev.to/sixset/playwright-docker-how-i-set-up-e2e-gating-for-devset-ce-and-what-bit-me-3oj</guid>
      <description>&lt;p&gt;Before every merge to &lt;code&gt;main&lt;/code&gt; I had this moment: "well, &lt;em&gt;probably&lt;/em&gt; nothing broke." Unit tests green, integration tests green, I click around the UI manually — works. And still, that little voice in the back of my head: "did you check message dispatch after the last change in &lt;code&gt;collectionContext&lt;/code&gt;? schema save from the Ace editor? SPA routing after the frontend gets bundled into the jar?"&lt;/p&gt;

&lt;p&gt;Eventually I got tired of it. I opened a branch called &lt;code&gt;ci-add-playwright-e2e-gating-main-with-bundled-FE+BE&lt;/code&gt; (yes, my branch names are a whole separate topic) and wrote proper E2E tests. Playwright, Docker, a gate on PRs. Here's how it looks — and where I faceplanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick context: what is Devset CE?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://devset.pl" rel="noopener noreferrer"&gt;Devset CE&lt;/a&gt; is my source-available side project: a tool for working with Kafka and RabbitMQ — schema repository (JSON / protobuf), message workflows, dispatching, connection management. Architecturally it's a Spring Boot backend that serves its SPA frontend from inside a single jar, also shipped as a Docker image (&lt;code&gt;ghcr.io/devset-io/devset-ce&lt;/code&gt;). That one-jar detail matters for this whole story: the E2E suite has to test &lt;em&gt;that exact artifact&lt;/em&gt;, not a dev server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went in
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Playwright in &lt;code&gt;devset-ce-fe/e2e/&lt;/code&gt; — two spec files for now: &lt;code&gt;smoke.spec.ts&lt;/code&gt; (the SPA mounts, &lt;code&gt;/api/workflows&lt;/code&gt; returns 200) and &lt;code&gt;schema-repo.spec.ts&lt;/code&gt; (create a JSON schema, create a protobuf one, edit the body, reload, verify it persisted).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.e2e.yml&lt;/code&gt; — Kafka, RabbitMQ, the backend with the frontend baked in, and a Playwright container. Healthchecks, pinned SHAs, &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;service_healthy&lt;/code&gt;. The whole package.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/e2e-build.sh&lt;/code&gt; — builds the frontend, drops it into &lt;code&gt;devset-ce-be/src/main/resources/static&lt;/code&gt;, runs &lt;code&gt;./gradlew bootJar&lt;/code&gt;. Out comes a single artifact, exactly like the one behind &lt;code&gt;ghcr.io/devset-io/devset-ce:latest&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.github/workflows/e2e.yml&lt;/code&gt; — runs on PRs and on push to &lt;code&gt;main&lt;/code&gt;. Cancel-in-progress for PRs (if someone pushes 10 commits in 5 minutes, I don't want 10 runs). Trace upload on failure.&lt;/li&gt;
&lt;li&gt;Four npm scripts: &lt;code&gt;e2e:build&lt;/code&gt;, &lt;code&gt;e2e:up&lt;/code&gt;, &lt;code&gt;e2e:down&lt;/code&gt;, &lt;code&gt;e2e:full&lt;/code&gt;. Locally I run &lt;code&gt;npm run e2e:full&lt;/code&gt; and get exactly what CI gets. No more "works on my machine."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While I was at it, I deleted the old &lt;code&gt;docker-compose.yml&lt;/code&gt; from the repo root. It was a zombie from when the project had a different structure — it pointed at a folder that no longer existed, and the README described a workaround for the workaround. Better to have nothing than something that lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Docker instead of Playwright's &lt;code&gt;webServer&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;My first thought was the simplest one: Playwright has &lt;code&gt;webServer&lt;/code&gt; in its config — spin up the backend with Gradle, Kafka via Testcontainers, done. I talked myself out of it within about 15 minutes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backend doesn't start "instantly."&lt;/strong&gt; Spring Boot has to boot and get on speaking terms with Kafka and RabbitMQ — only then is &lt;code&gt;/api/*&lt;/code&gt; actually alive. &lt;code&gt;sleep 10&lt;/code&gt; in the config? No, thanks. In docker-compose I have healthchecks and &lt;code&gt;condition: service_healthy&lt;/code&gt;, and it just… happens on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kafka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;rabbitmq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;I want local and CI to be identical.&lt;/strong&gt; Not 95% identical, not "well, almost." One compose file, one script, two places to run it. When something breaks in CI, I run &lt;code&gt;npm run e2e:full&lt;/code&gt; on my machine and reproduce the failure in a minute. That's a currency you pay once — and it pays you back daily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The browser version ships with Playwright.&lt;/strong&gt; The &lt;code&gt;mcr.microsoft.com/playwright:v1.59.0-noble&lt;/code&gt; image comes with a Chromium already matched to &lt;code&gt;@playwright/test@1.59.0&lt;/code&gt;. Nobody forgets &lt;code&gt;npx playwright install&lt;/code&gt;, nobody debugs "why does it work on Janek's machine."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And I'm testing the real artifact.&lt;/strong&gt; Not a dev Vite server with a proxy to the backend, but the jar that serves the frontend from &lt;code&gt;static/&lt;/code&gt;. That catches regressions in CORS, in SPA routing, in resource mapping. A mock server would never see them, because it simply never exercises those paths.&lt;/p&gt;

&lt;p&gt;The cost? The first run is slower, but with npm/Gradle caches in GitHub Actions the whole workflow fits in about 6–8 minutes. Acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bit me (and what surprised me)
&lt;/h2&gt;

&lt;p&gt;A few things ate a meaningful chunk of my time. Writing them down so I have them handy next time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Ace editor.&lt;/strong&gt; JSON schemas are edited in Ace. Ace renders its own DOM, keeps a hidden textarea, &lt;code&gt;page.fill()&lt;/code&gt; is a coin flip, &lt;code&gt;keyboard.type()&lt;/code&gt; is slow and keymap-sensitive. I started with three "normal" approaches — none of them was stable. Only after opening devtools and poking around Ace's internals did I notice it keeps its Editor instance at &lt;code&gt;containerElement.env.editor&lt;/code&gt; (Ace uses it itself, as a re-entry guard in &lt;code&gt;ace.edit()&lt;/code&gt;). So I go in through &lt;code&gt;page.evaluate()&lt;/code&gt; and call &lt;code&gt;editor.setValue(value, -1)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;container&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;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.ace_editor&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;container&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&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;-&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="nx"&gt;schemaBody&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ugly? Ugly. But it works every single time. I left a &lt;code&gt;// SAFETY:&lt;/code&gt; comment so that future me, staring at the &lt;code&gt;as unknown as&lt;/code&gt;, doesn't rip it out with a "what even is this."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite chased me off.&lt;/strong&gt; Devset keeps its state in SQLite. Playwright runs tests within a single file in parallel by default. SQLite serializes all writes — so when three tests fire a &lt;code&gt;DELETE&lt;/code&gt; at once, two of them get &lt;code&gt;SQLITE_BUSY&lt;/code&gt; and everything goes red. I tried retries, more aggressive cleanup, combinations of both — and finally let go:&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="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serial&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;on the schema spec file. Slower, but 100% green. Retry with backoff masks the problem; it doesn't solve it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;getByRole&lt;/code&gt; does substring matching.&lt;/strong&gt; So &lt;code&gt;getByRole('button', { name: 'Edit' })&lt;/code&gt; blew up with a strict-mode violation: "found 2 elements." Because the sidebar had a node whose ID contained the word "edit" — my edit test had named its schema &lt;code&gt;e2e_json_edit_…&lt;/code&gt; (brilliant, past me). Lesson: &lt;code&gt;exact: true&lt;/code&gt; everywhere I know the full text. It should be the default; it isn't; oh well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cleanup after failures.&lt;/strong&gt; E2E tests against a shared backend have this property: when something dies halfway through, garbage stays behind. So every test appends the IDs it created to a list, and in &lt;code&gt;afterEach&lt;/code&gt; I fire a &lt;code&gt;Promise.all&lt;/code&gt; of best-effort &lt;code&gt;DELETE&lt;/code&gt;s (&lt;code&gt;.catch(() =&amp;gt; undefined)&lt;/code&gt;). The next run always starts clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got out of it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Merges to &lt;code&gt;main&lt;/code&gt; are gated. Red E2E = it doesn't go in. No more "I trust nothing broke."&lt;/li&gt;
&lt;li&gt;Playwright traces on failure. I download the zip from the workflow artifacts, open it, and I've got a screenshot + recording + network log. Debugging a remote failure is five clicks now, not an hour-long investigation.&lt;/li&gt;
&lt;li&gt;I'm testing the same jar that ships to production. Frontend embedded in the backend, exactly like the GHCR image. If something works locally and breaks after a release, it's no longer the E2E suite's fault.&lt;/li&gt;
&lt;li&gt;Locally it's one command: &lt;code&gt;npm run e2e:full&lt;/code&gt;. A new contributor: &lt;code&gt;git clone&lt;/code&gt;, &lt;code&gt;npm run e2e:full&lt;/code&gt;, goes to make a coffee, comes back to a result. The barrier to entry just dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Smoke + schema repo is just the start. On the list: workflows (create / edit / run), message dispatch with &lt;code&gt;collectionContext&lt;/code&gt; (a fresh feature — the perfect moment to cement it), connection management for Kafka and RabbitMQ. Every new screen gets its own spec — that's now part of "feature done," not an optional add-on for later.&lt;/p&gt;

&lt;p&gt;If any of these tests turns flaky, that'll be its own post. Because a flaky test nobody fixes is worse than no test at all — it teaches the team to ignore a red status, and that's a habit that's very hard to unlearn.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Devset CE is source-available — you can poke around the code, run it from a single jar or the Docker image (&lt;code&gt;ghcr.io/devset-io/devset-ce&lt;/code&gt;), and tell me everything I did wrong. Start at &lt;a href="https://devset.pl" rel="noopener noreferrer"&gt;devset.pl&lt;/a&gt; or the &lt;a href="https://github.com/devset-io" rel="noopener noreferrer"&gt;devset-io org on GitHub&lt;/a&gt;. Issues, feedback and PRs very welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>docker</category>
      <category>testing</category>
      <category>cicd</category>
    </item>
    <item>
      <title>How I Improved Code Quality with AI and Static Analysis (SonarQube Cloud + MCP)</title>
      <dc:creator>SixSet</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:45:09 +0000</pubDate>
      <link>https://dev.to/sixset/how-i-improved-code-quality-with-ai-and-static-analysis-sonarqube-cloud-mcp-16c3</link>
      <guid>https://dev.to/sixset/how-i-improved-code-quality-with-ai-and-static-analysis-sonarqube-cloud-mcp-16c3</guid>
      <description>&lt;p&gt;The more code gets written with AI assistance, the easier it is to fall into a false sense of security. It compiles, tests pass, everything &lt;em&gt;looks&lt;/em&gt; fine but underneath there can still be vulnerabilities, dead code, or metrics that don't actually mean anything because they're only computed halfway. So instead of stacking yet another "eyeball" AI review on top, I decided to add a hard, automated layer to my project: static code analysis that checks every PR against the same, measurable criteria.&lt;/p&gt;

&lt;p&gt;I went with &lt;strong&gt;SonarQube Cloud&lt;/strong&gt;, mainly because it has a free plan for open-source projects, and it also ships its own MCP server meaning the AI agent I work with can pull analysis results directly, without me copy-pasting reports back and forth. Below is a step-by-step account of how this rollout went in &lt;a href="https://github.com/devset-io/devset-ce" rel="noopener noreferrer"&gt;devset-ce&lt;/a&gt;, my local testing engine for event-driven systems on Kafka and RabbitMQ, based on three concrete commits and what real benefits it brought.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The analysis workflow in CI
&lt;/h2&gt;

&lt;p&gt;Starting point: &lt;a href="https://github.com/devset-io/devset-ce/commit/a6746d3e369ea7423afabb0ec83150baa14f9918" rel="noopener noreferrer"&gt;commit a6746d3&lt;/a&gt;, PR #44 adding &lt;code&gt;.github/workflows/sonar.yml&lt;/code&gt; and &lt;code&gt;sonar-project.properties&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The workflow builds the backend (Gradle, JDK 25), installs and tests the frontend (Node 22), and finally runs the official &lt;code&gt;SonarSource/sonarqube-scan-action&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build backend&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;devset-ce-be&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./gradlew build -x test&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test frontend&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;devset-ce-fe&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run test&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SonarQube Scan&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e&lt;/span&gt; &lt;span class="c1"&gt;# v8.2.0&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;SONAR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;./gradlew build -x test&lt;/code&gt; backend tests were skipped at this stage. That was a deliberate first step, but it also created an immediately visible gap: Sonar got coverage from the frontend (via &lt;code&gt;sonar.javascript.lcov.reportPaths&lt;/code&gt;), but none at all from the backend.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sonar-project.properties&lt;/code&gt; sets sources separately for backend and frontend, excludes &lt;code&gt;build/&lt;/code&gt;, &lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt;, and test files from the main-code analysis, and points to backend tests separately via &lt;code&gt;sonar.tests&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Real coverage with JaCoCo
&lt;/h2&gt;

&lt;p&gt;Step two was &lt;a href="https://github.com/devset-io/devset-ce/commit/91822a8b85b1bd215518471b496e07a0a8eedbba" rel="noopener noreferrer"&gt;commit 91822a8&lt;/a&gt;, PR #50 &lt;em&gt;"wire backend JaCoCo coverage into SonarQube analysis"&lt;/em&gt;. This directly addresses the gap from step 1.&lt;/p&gt;

&lt;p&gt;Changes to &lt;code&gt;build.gradle&lt;/code&gt;: adding the &lt;code&gt;jacoco&lt;/code&gt; plugin (pinned to version 0.8.14), hooking it into the &lt;code&gt;test&lt;/code&gt; and &lt;code&gt;integTest&lt;/code&gt; tasks via &lt;code&gt;finalizedBy jacocoTestReport&lt;/code&gt;, and configuring the report to merge execution data from both unit and integration tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gradle"&gt;&lt;code&gt;&lt;span class="n"&gt;plugins&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="s1"&gt;'java'&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="s1"&gt;'jacoco'&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;jacoco&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;toolVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.8.14"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;jacocoTestReport&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dependsOn&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;
    &lt;span class="n"&gt;executionData&lt;/span&gt; &lt;span class="nf"&gt;fileTree&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buildDirectory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"jacoco"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt; &lt;span class="s2"&gt;"*.exec"&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;reports&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the workflow, the backend build step changes from skipping tests to running the full test + report cycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build backend with coverage&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;devset-ce-be&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./gradlew build integTest jacocoTestReport&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;sonar-project.properties&lt;/code&gt; gets one new line tying it all together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;sonar.coverage.jacoco.xmlReportPaths&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;devset-ce-be/build/reports/jacoco/test/jacocoTestReport.xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result: SonarQube now calculates real backend code coverage (unit + integration), not just frontend numbers. This is what finally makes the coverage metric in Sonar trustworthy before this, it was partial and could give a false sense of security.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. UTF-8, where you least expect to need it
&lt;/h2&gt;

&lt;p&gt;The third change is &lt;a href="https://github.com/devset-io/devset-ce/commit/4921e9ca7508b7e93501bb847a491940b3433946" rel="noopener noreferrer"&gt;commit 4921e9c&lt;/a&gt;, PR #54 a single line in &lt;code&gt;build.gradle&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gradle"&gt;&lt;code&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JavaCompile&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;configureEach&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encoding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compilerArgs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;"-parameters"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without an explicit encoding setting, the Java compiler falls back to the platform's default encoding which can be inconsistent across different CI environments and can silently corrupt non-ASCII characters in sources or resources. A small fix, but exactly the kind that's easy to overlook until you start looking for it systematically instead of by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually gets you
&lt;/h2&gt;

&lt;p&gt;After these three commits, I have a genuinely different starting point than before, when the only line of defense was a human code review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A quality gate that blocks merges.&lt;/strong&gt; New code that doesn't meet the thresholds (duplication, code smells, security hotspots) doesn't make it into &lt;code&gt;main&lt;/code&gt; nobody has to police this manually in review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trustworthy coverage instead of a number for show.&lt;/strong&gt; Before PR #50, the backend built with &lt;code&gt;-x test&lt;/code&gt;, so the coverage metric in Sonar was essentially fiction. After wiring in JaCoCo (unit + integration), backend coverage reflects what's actually tested, so I can rely on that number when deciding where tests are missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security hotspots and CVEs caught automatically.&lt;/strong&gt; Static analysis plus the CVE resolution strategy in Gradle (PR #54) catch vulnerable dependencies and suspicious patterns before anyone sees them in review I'm no longer relying on a reviewer happening to remember a specific CVE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A whole class of bugs that normally slip through review.&lt;/strong&gt; The UTF-8 encoding fix is a good example it's not something a human typically notices while reading a diff, yet it can silently corrupt data on a different CI environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A shorter feedback loop thanks to MCP.&lt;/strong&gt; The AI agent gets analysis results with full context (file, line, issue type) and proposes a fix itself I don't have to manually copy a report from a dashboard into my editor, so small fixes don't get pushed to "later."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics as living documentation of the project's state.&lt;/strong&gt; Coverage, duplication, and open issue counts in Sonar give a measurable snapshot of repo quality at any point in time useful when an outside contributor is deciding whether it's worth getting involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;These three commits are a good illustration of what adding static analysis to an existing project actually looks like in practice: not a one-time "big bang," but a series of small iterations. First, a basic workflow even with tests skipped. Then, filling in real coverage, because without it the metrics are only half the picture. Finally, a small fix that the analysis (or the build itself) would sooner or later have forced anyway.&lt;/p&gt;

&lt;p&gt;What really speeds up this loop is SonarQube's MCP server the AI agent gets direct access to analysis results and context (which file, which line, what type of issue), so going from a flagged issue to an actual code fix is faster than manually shuttling reports between a dashboard and an editor.&lt;/p&gt;

&lt;p&gt;The developer, the AI agent, and static analysis each do a different job here: the developer decides what makes business sense, AI speeds up the actual work, and Sonar makes sure neither side introduces a regression. Wired together in CI/CD, this means the quality gate and real coverage metrics run on every PR, before anything reaches &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This workflow is part of &lt;a href="https://github.com/devset-io/devset-ce" rel="noopener noreferrer"&gt;devset-ce&lt;/a&gt; a local, source-available testing engine for event-driven systems on Kafka and RabbitMQ, built by [DevSet]. If you're working with message brokers and tired of writing the same Kafka scripts over and over, it might save you some time feel free to check it out, star it, or open an issue.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>codequality</category>
      <category>testing</category>
      <category>java</category>
      <category>ai</category>
    </item>
    <item>
      <title>A Better Way to Test Kafka and RabbitMQ Workflows</title>
      <dc:creator>SixSet</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:29:03 +0000</pubDate>
      <link>https://dev.to/sixset/a-better-way-to-test-kafka-and-rabbitmq-workflows-3p1j</link>
      <guid>https://dev.to/sixset/a-better-way-to-test-kafka-and-rabbitmq-workflows-3p1j</guid>
      <description>&lt;p&gt;This isn't a Kafka tutorial. It's about how I stopped writing disposable producer scripts every time I needed to test an event-driven workflow.&lt;/p&gt;

&lt;p&gt;I keep running into the same situation. I'm testing a feature in a distributed system where the actions are loosely connected through events a user makes a payment or a purchase, and that has to start propagating: to accounting, to inventory, to whatever downstream service is waiting for it.&lt;/p&gt;

&lt;p&gt;The problem is that on test environments, I rarely have the full chain available. Either the other services aren't deployed yet, or the functionality that would normally trigger the event just doesn't exist yet. So I need to fake that one event myself.&lt;/p&gt;

&lt;p&gt;Most of the time this ended up being one of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kafka-console-producer&lt;/code&gt; with a hand-typed payload&lt;/li&gt;
&lt;li&gt;a small producer script in Python or Go, written once and never cleaned up&lt;/li&gt;
&lt;li&gt;a throwaway Spring Boot app whose only job was to fire one event&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these work. But after a few months, your repo (or your &lt;code&gt;~/scripts&lt;/code&gt; folder) turns into a graveyard of one-off producers, each slightly different, each half-remembered when you need it again six weeks later.&lt;/p&gt;

&lt;p&gt;I got tired of that pattern enough to build Devset, a tool for testing event-driven systems without writing throwaway producers and consumers. This is a walkthrough of the three specific problems that pushed me to build it.&lt;/p&gt;

&lt;p&gt;The examples below use Kafka, but the same workflow applies to RabbitMQ exchanges and routing keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending a single event
&lt;/h2&gt;

&lt;p&gt;Sometimes I just need to fire one message a specific payload, on a specific topic, with a specific key and see what happens downstream. &lt;code&gt;kafka-console-producer&lt;/code&gt; technically solves this problem, but in practice it still means constructing JSON by hand, remembering CLI flags, and repeating the whole process every time you want to replay the same event.&lt;/p&gt;

&lt;p&gt;Instead of writing a producer every time, I paste the payload, pick the topic and key, and publish it. It gets saved, so the next time I need the same message I'm not reconstructing it from memory.&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%2F66xypqg08f3x2y3tiqer.gif" 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%2F66xypqg08f3x2y3tiqer.gif" alt="Pasting a payload, picking a topic/key, and publishing it — the message shows up on the topic immediately" width="760" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Replaying complete workflows
&lt;/h2&gt;

&lt;p&gt;This is the part that actually mattered more than sending single messages.&lt;/p&gt;

&lt;p&gt;Sometimes one event isn't enough I need to simulate a whole sequence. A user adds something to a cart, enters an amount, pays, and a separate service should confirm the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OrderCreated
     │
     ▼
InventoryReserved
     │
     ▼
PaymentAuthorized
     │
     ▼
InvoiceGenerated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's a simplified example, but real workflows usually branch here. Inventory may fail, payment may time out, an order may need to be retried, or a compensating event may need to be emitted. Those are the scenarios that quickly turn a handful of producer scripts into something nobody wants to maintain.&lt;/p&gt;

&lt;p&gt;Normally, testing that chain means either standing up four real services, or writing four scripts and running them in the right order with the right delays between them and hoping nothing about the payload shapes changed since the last time you touched them.&lt;/p&gt;

&lt;p&gt;In Devset, this is one saved workflow. I lay the steps out on a canvas, each step can carry a condition, a delay, or reference state from an earlier step (so &lt;code&gt;PaymentAuthorized&lt;/code&gt; can carry the order ID that &lt;code&gt;OrderCreated&lt;/code&gt; generated), and the whole thing runs as a single unit that I can re-trigger any time I need it — instead of four services or four scripts, it's one saved workflow.&lt;/p&gt;

&lt;p&gt;The same workflows also let me observe responses coming back, so I'm not only publishing events but exercising complete request/reaction chains.&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%2F7knlxupr7mcuqc24r1ug.gif" 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%2F7knlxupr7mcuqc24r1ug.gif" alt="Building the four-step chain above on the Flow Builder canvas and running it end to end" width="599" height="368"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Working with Protobuf
&lt;/h2&gt;

&lt;p&gt;A chunk of my day-to-day work is on services that speak Protobuf, and firing Protobuf messages from a script is a different level of annoying than JSON. You need the schema on hand, you're dealing with compiled classes or manual encoding, and the moment the schema changes across environments you're debugging a wire-format mismatch instead of the thing you actually meant to test.&lt;/p&gt;

&lt;p&gt;I keep the schemas I use versioned in one place now, and messages can be built against JSON Schema or Protobuf the same way — the encoding and validation happen automatically instead of being something I have to get right by hand every time.&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%2Faimvgx7m3n2h3lfseetb.gif" 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%2Faimvgx7m3n2h3lfseetb.gif" alt="Picking a Protobuf schema and sending a binary-encoded message the same way as the JSON one" width="720" height="442"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Where this is going
&lt;/h2&gt;

&lt;p&gt;Devset CE is source-available and self-hosted, and runs locally against your own brokers with a single Docker command. If you'd like to try it, the repository and documentation are linked below.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/devset-io" rel="noopener noreferrer"&gt;
        devset-io
      &lt;/a&gt; / &lt;a href="https://github.com/devset-io/devset-ce" rel="noopener noreferrer"&gt;
        devset-ce
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Local-first workflow engine for testing Kafka and RabbitMQ event-driven systems. Define scenarios, send real messages, inspect results — all on your machine.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/devset-io/devset-ce/docs/images/devset-banner-v4@2x.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdevset-io%2Fdevset-ce%2FHEAD%2Fdocs%2Fimages%2Fdevset-banner-v4%402x.png" width="100%" alt="Devset"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  &lt;em&gt;Local message lab for Kafka and RabbitMQ — no cloud, no scripts.&lt;/em&gt;
&lt;/p&gt;
&lt;p&gt;
  &lt;a href="https://github.com/devset-io/devset-ce/LICENSE" rel="noopener noreferrer"&gt;&lt;img alt="License" src="https://camo.githubusercontent.com/c1bc7fe82ef8aece9940a0bb0bf58241ee3d1734ca0e98e7b3fb75d9afddaaa4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d46534c2d2d312e312d2d4170616368652d2d322e302d3465623538613f7374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/devset-io/devset-ce/releases" rel="noopener noreferrer"&gt;&lt;img alt="Release" src="https://camo.githubusercontent.com/3e736c3b80f820ea57e1d02bb2a72f6d697b41ee22760207dc6f60371173abed/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f762f72656c656173652f6465767365742d696f2f6465767365742d63653f636f6c6f723d346562353861267374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/devset-io/devset-ce/stargazers" rel="noopener noreferrer"&gt;&lt;img alt="Stars" src="https://camo.githubusercontent.com/16711e157d5d905d262ce94a9206451c97cf9bf4557a4f10768b6fa93fe7ac4c/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f73746172732f6465767365742d696f2f6465767365742d63653f636f6c6f723d346562353861267374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/devset-io/devset-ce/actions/workflows/ci.yml?query=branch%3Amain" rel="noopener noreferrer"&gt;&lt;img alt="CI" src="https://camo.githubusercontent.com/743bad5df06a54d1621dfc1ced6270ee9af8b3fd7640411687c2a3d8dfdec3bb/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6465767365742d696f2f6465767365742d63652f63692e796d6c3f6272616e63683d6d61696e266c6162656c3d6369267374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/devset-io/devset-ce/actions/workflows/e2e.yml?query=branch%3Amain" rel="noopener noreferrer"&gt;&lt;img alt="E2E" src="https://camo.githubusercontent.com/d7d2e2329e6eb3562463b3fc88297503873a248b1c2102bf13f9d494b16c76a6/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6465767365742d696f2f6465767365742d63652f6532652e796d6c3f6272616e63683d6d61696e266c6162656c3d653265267374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/devset-io/devset-ce/actions/workflows/release.yml" rel="noopener noreferrer"&gt;&lt;img alt="Release" src="https://camo.githubusercontent.com/25e4e3120004c9f38b17c7b0cc52e07137e09935ab5b437ec1f1df4b8ccfdd1d/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6465767365742d696f2f6465767365742d63652f72656c656173652e796d6c3f6c6162656c3d72656c65617365267374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/0cac086a4ea45b44f3c5c6629d0b4fd9c339a962a4f3ef513603d6d748348da7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6a6176612d32352d3465623538613f7374796c653d666c61742d737175617265"&gt;&lt;img alt="Java" src="https://camo.githubusercontent.com/0cac086a4ea45b44f3c5c6629d0b4fd9c339a962a4f3ef513603d6d748348da7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6a6176612d32352d3465623538613f7374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f74db4f5e319ba38286e1caa19a277cbdcc3528fc5e7c244660a297c4856e186/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f72656163742d31392d3465623538613f7374796c653d666c61742d737175617265"&gt;&lt;img alt="React" src="https://camo.githubusercontent.com/f74db4f5e319ba38286e1caa19a277cbdcc3528fc5e7c244660a297c4856e186/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f72656163742d31392d3465623538613f7374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a href="https://sonarcloud.io/summary/new_code?id=devset-io_devset-ce" rel="nofollow noopener noreferrer"&gt;&lt;img alt="SonarQube" src="https://camo.githubusercontent.com/3d5ae66feabbb1b9da240ad6b7f6e63ba0e75095b7d7c1d8b9d4faeb96597dd9/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7363616e6e65642532306f6e2d536f6e6172517562652d3465396263643f7374796c653d666c61742d737175617265266c6f676f3d736f6e617271756265266c6f676f436f6c6f723d7768697465"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Devset&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Testing event-driven systems is painful. You end up writing throwaway scripts to publish a few messages, integration tests are brittle and slow, multi-step scenarios need glue code nobody wants to maintain, and reproducing production payloads locally is a guessing game. Devset is a self-hosted scenario engine that compiles a declarative DSL into runs against real Kafka or RabbitMQ brokers, with a visual builder on top.&lt;/p&gt;

&lt;p&gt;Think Postman or Cypress, but for Kafka and RabbitMQ instead of HTTP.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No cloud.&lt;/strong&gt; Runs entirely on your machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No accounts.&lt;/strong&gt; Open the UI and start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No telemetry.&lt;/strong&gt; Your scenarios stay local.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source-available under FSL-1.1 — free for internal use; each release converts to Apache 2.0 on its second anniversary.&lt;/p&gt;




&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/devset-io/devset-ce/docs/images/Flow%20Builder.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdevset-io%2Fdevset-ce%2FHEAD%2Fdocs%2Fimages%2FFlow%2520Builder.png" alt="Flow Builder"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quick start&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;1. Run Devset&lt;/h3&gt;
&lt;/div&gt;

&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker run -p 8082:8082 -v devset-data:/data ghcr.io/devset-io/devset-ce:latest&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://localhost:8082" rel="nofollow noopener noreferrer"&gt;http://localhost:8082&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;


&lt;blockquote&gt;

&lt;p&gt;&lt;strong&gt;Already seeded — nothing to set up&lt;/strong&gt;…&lt;/p&gt;


&lt;/blockquote&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/devset-io/devset-ce" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://docs.devset.io" rel="noopener noreferrer"&gt;https://docs.devset.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few things I'm currently working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deeper branching in the Flow Builder, so workflows can handle retries, timeouts, and compensating events instead of just linear chains&lt;/li&gt;
&lt;li&gt;live topic inspection&lt;/li&gt;
&lt;li&gt;offline workflow simulation (run the pipeline without touching a real broker)&lt;/li&gt;
&lt;li&gt;a JSON DSL so the same workflows can run from CI, not just the UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you work with Kafka or RabbitMQ and have opinions, issues, or PRs, I'd genuinely like to hear them.&lt;/p&gt;




&lt;p&gt;How are you testing event chains like this today real services, mocks, something else? Curious what other people have landed on.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>eventdriven</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
