<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Blogs]]></title><description><![CDATA[Blogs]]></description><link>https://hempun.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6415d686bc961bc7aa598660/7ed59c0c-fc32-49fc-a386-614b713f70f5.jpg</url><title>Blogs</title><link>https://hempun.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 07 Sep 2026 17:01:37 GMT</lastBuildDate><atom:link href="https://hempun.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How Passmark caught three real bugs in my vibe-coded realtime chat app]]></title><description><![CDATA[TL;DR
I built a small realtime stranger-chat app called Lume the way most of us build things now: with a model in the loop, shipping fast, skipping the boring tests. Then I pointed Passmark at it.
Pas]]></description><link>https://hempun.hashnode.dev/how-passmark-caught-three-real-bugs-in-my-vibe-coded-realtime-chat-app</link><guid isPermaLink="true">https://hempun.hashnode.dev/how-passmark-caught-three-real-bugs-in-my-vibe-coded-realtime-chat-app</guid><category><![CDATA[breakingappshackathon]]></category><dc:creator><![CDATA[Hem Bahadur Pun]]></dc:creator><pubDate>Sun, 10 May 2026 08:18:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6415d686bc961bc7aa598660/53978fcf-9352-4777-8c70-dcd4f4f6c903.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<img alt="" />

<h2>TL;DR</h2>
<p>I built a small realtime stranger-chat app called <strong>Lume</strong> the way most of us build things now: with a model in the loop, shipping fast, skipping the boring tests. Then I pointed <strong>Passmark</strong> at it.</p>
<p>Passmark drives the app with natural-language steps and judges the result with screenshots and prose. I wrote a custom email provider so it could read OTP codes from local Mailpit, and I pinned everything to <code>google/gemini-2.5-flash</code> so my OpenRouter bill stayed predictable.</p>
<p>It found three bugs I had been staring past for weeks:</p>
<ol>
<li><p>A Supabase RLS policy that silently broke chat prompts for every match.</p>
</li>
<li><p>A password-recovery flow that only worked when I tested it by hand.</p>
</li>
<li><p>A hydration race that sometimes posted login credentials into the URL.</p>
</li>
</ol>
<p>Repo: <a href="https://github.com/hempun10/lume">https://github.com/hempun10/lume</a></p>
<p>Live demo: <a href="https://lume-roan.vercel.app">https://lume-roan.vercel.app</a></p>
<h2>1. What is Lume?</h2>
<p>Lume is a safer, game-forward take on Omegle-style random chat. The pieces:</p>
<ul>
<li><p>Email/password auth, 18+ consent, DOB validation</p>
</li>
<li><p>Onboarding (display name, gender, region, 1–8 interests)</p>
</li>
<li><p>Protected dashboard with TanStack Router route guards</p>
</li>
<li><p>Matchmaking via Supabase Realtime, a Postgres <code>match_queue</code>, and a <code>pg_cron</code> Edge Function that scores candidates by interest overlap, region, and age</p>
</li>
<li><p>Ephemeral 1:1 chat (no persistence)</p>
</li>
<li><p>Inline games over Supabase Broadcast, with Tic Tac Toe as the canary</p>
</li>
<li><p>Report and block flows that exclude pairs from future matches</p>
</li>
<li><p>Forgot-password via 6-digit email OTP</p>
</li>
</ul>
<p>That is a lot of state. A lot of it is invisible at rest. Most of the bugs I cared about lived between two browsers, two Supabase channels, and a redirect.</p>
<p>Selector-based E2E tests do not love that shape. I wanted something that could read the page like a person and tell me whether the <em>behaviour</em> was right.</p>
<h2>2. The vibe-coded era needs different tests</h2>
<p>Most of Lume came together during long sessions of "ask a model, paste, run, swear, ask again." That style ships a working surface fast, but the surface and the data flow lie to each other constantly. The login button works. The chat opens. The cards render. None of that proves the right rows came back from Postgres.</p>
<p>Three Passmark properties matched that risk:</p>
<ul>
<li><p><strong>Steps describe intent, not markup.</strong> "Click 'Start matching'" is a contract with the user. <code>[data-testid="lobby-cta"]</code> is a contract with my last refactor.</p>
</li>
<li><p><strong>Snapshot judging tolerates non-determinism.</strong> Lume's matchmaker is non-deterministic when more than two people are in the queue. Asking the AI to confirm "Bob's timeline contains a message from the stranger that says 'hello from passmark'" is much kinder than chasing a <code>[data-message-id]</code>.</p>
</li>
<li><p><strong>Accessibility falls out for free.</strong> If Passmark cannot find the button by its label, neither can a screen reader. Every test I wrote made the app slightly more readable.</p>
</li>
</ul>
<p>In short: when the code is half mine and half the model's, I want a checker that judges the experience, not the DOM I no longer fully remember.</p>
<h2>3. How I actually use Passmark</h2>
<p>The whole driver lives in <code>playwright.config.ts</code>. One <code>configure</code> call, one custom email provider:</p>
<pre><code class="language-ts">// playwright.config.ts
import { configure } from "passmark";
import { mailpitProvider } from "./e2e/passmark/mailpit-provider";

configure({
  ai: {
    gateway: "openrouter",
    models: {
      stepExecution: "google/gemini-2.5-flash",
      assertionPrimary: "google/gemini-2.5-flash",
      assertionArbiter: "google/gemini-2.5-flash",
      // ...all roles pinned to the same model
    },
  },
  email: mailpitProvider(),
});
</code></pre>
<p>Inside a spec, I write tests in two layers. Boring setup is plain Playwright. The flow under test is Passmark steps:</p>
<pre><code class="language-ts">// e2e/passmark/dashboard-lobby.spec.ts
import { test, expect } from "@playwright/test";
import { runLumeSteps, loginAsSeededUser } from "./helpers";

test("a user can start and cancel a match", async ({ page }) =&gt; {
  await loginAsSeededUser(page, "alice"); // plain Playwright

  await runLumeSteps({
    page, test, expect,
    userFlow: "Start matching, then cancel before pairing",
    steps: [
      { description: "Click the 'Start matching' button on the dashboard" },
      { description: "Wait until the lobby shows a 'Searching for someone…' state" },
      { description: "Click the 'Cancel' button" },
      { description: "Confirm the lobby is back to the idle state" },
    ],
  });
});
</code></pre>
<p>Four design choices made the suite worth keeping:</p>
<ul>
<li><p><strong>Pin the model.</strong> Without <code>google/gemini-2.5-flash</code> locked across every Passmark role, OpenRouter occasionally routed to a model that returned 400s on tool calls. Pinning made every run reproducible.</p>
</li>
<li><p><strong>Deterministic auth, AI behaviour.</strong> Logging in is not the test target in 95% of specs. <code>loginAsSeededUser</code> uses Playwright. Passmark only kicks in for the actual flow under test. Saves credits, removes flake.</p>
</li>
<li><p><strong>One concern per Passmark test.</strong> Snapshot AI judging works much better with focused assertions. I split the original <code>dashboard-settings.spec.ts</code> into three smaller files.</p>
</li>
<li><p><strong>Bumped Supabase's local sign-in/up rate limit.</strong> Default is 30 / 5 min. Parallel runs eat through that. Bumped to 300 in <code>supabase/config.toml</code>, committed.</p>
</li>
</ul>
<h2>4. Three bugs Passmark caught</h2>
<h3>Bug #1 — A silent RLS policy gap broke chat prompts for everyone</h3>
<p>This is my favourite, because the app <em>looked</em> fine.</p>
<p>When two users matched, the chat opened with a "Break the ice!" panel showing prompt cards. I had wired <code>&lt;PromptSuggestions /&gt;</code> to take <code>strangerProfile.interests</code> and generate themed conversation starters. A stranger with <code>Music</code> in their interests was supposed to see music prompts.</p>
<p>Every chat fell back to the generic prompt set ("Pineapple on pizza — defend your stance"). I never noticed because:</p>
<ul>
<li><p>The fallback prompts are reasonable.</p>
</li>
<li><p>I tested locally as a single user.</p>
</li>
<li><p>No selector test ever asserted the <em>content</em> of the cards.</p>
</li>
</ul>
<p>When I added a <code>&lt;SharedInterestsBanner /&gt;</code> for the realtime test ("You both like Music · Cooking"), the banner refused to render. Same root cause.</p>
<p>The bug:</p>
<pre><code class="language-sql">-- supabase/migrations/...add_profile_fields.sql (original)
CREATE POLICY "Users can read own profile"
  ON public.profiles FOR SELECT
  USING (auth.uid() = id);
</code></pre>
<p>That was the <strong>only</strong> SELECT policy on <code>profiles</code>. So <code>useStrangerProfile</code>'s <code>select("display_name, interests").eq("id", strangerId)</code> returned an empty payload for every other user. No error, no exception, just zero rows.</p>
<p>The fix is a narrow second policy:</p>
<pre><code class="language-sql">-- supabase/migrations/20260502000000_allow_reading_room_counterpart_profile.sql
CREATE POLICY "Users can read room counterpart profile"
  ON public.profiles FOR SELECT
  TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM public.rooms r
      WHERE (r.user_a = auth.uid() AND r.user_b = profiles.id)
         OR (r.user_b = auth.uid() AND r.user_a = profiles.id)
    )
  );
</code></pre>
<p>A user can read another profile only if they share an active row in <code>public.rooms</code>. Profiles stay locked otherwise.</p>
<img alt="Lume chat: prompt cards with 'You both like Music · Cooking' shared-interests banner above interest-themed conversation starters" style="display:block;margin:0 auto" />

<p>The banner above and the four interest-themed cards underneath all read from the same Supabase query. Before the migration, every chat fell back to generics.</p>
<p>The point I want to land: Passmark made me write a <em>behavioural</em> assertion ("the banner says Music · Cooking"), and the assertion could not pass until the data flow was correct. A unit test on <code>useStrangerProfile</code> would have mocked Supabase and never seen this.</p>
<h3>Bug #2 — The magic-link recovery flow did not actually work end-to-end</h3>
<p>The original <code>/forgot-password</code> sent a magic link via <code>supabase.auth.resetPasswordForEmail(email, { redirectTo })</code>. Click the link, land on <code>/reset-password</code> with a recovery session, set a new password, done. That was the theory.</p>
<p>Local Supabase routes auth emails through Mailpit, so I assumed Passmark could "click" the link the way a user would. It cannot, cleanly. The link lives in an email body, in a separate tool, behind redirects. Every attempt to script it broke the next time the redirect chain shifted.</p>
<p>So I rewrote the flow as a 6-digit email OTP:</p>
<ul>
<li><p><code>/forgot-password</code> → enter email → "Send code" → redirect to <code>/reset-password?email=…</code></p>
</li>
<li><p><code>/reset-password</code> → enter code + new password + confirm → <code>verifyOtp({ type: "recovery" })</code> → <code>updateUser({ password })</code> → <code>/dashboard</code></p>
</li>
</ul>
<p>That left the question: how does Passmark actually pull a code out of Mailpit? I built a custom <code>EmailProvider</code>:</p>
<pre><code class="language-ts">// e2e/passmark/mailpit-provider.ts
import type { EmailProvider } from "passmark";

export function mailpitProvider(): EmailProvider {
  const baseUrl = process.env.MAILPIT_URL ?? "http://127.0.0.1:54324";
  return {
    domain: "@example.com",
    async extractContent({ email, prompt }) {
      // Poll Mailpit's /api/v1/messages for the latest message to `email`.
      // If `prompt` mentions "code" or "otp", regex out the 6-digit token.
      // Otherwise return the body verbatim.
    },
  };
}
</code></pre>
<p>Wired into Passmark with one line:</p>
<pre><code class="language-ts">configure({ email: mailpitProvider() });
</code></pre>
<p>The test uses the placeholder syntax to inline the OTP into a step:</p>
<pre><code class="language-ts">await runSteps({
  page, test,
  userFlow: "Reset password using the OTP from the recovery email",
  steps: [
    {
      description: "Fill the 'Verification code' field with the 6-digit code from the recovery email",
      data: { value: `{{email.otp:get the 6 digit verification code:${email}}}` },
    },
    { description: "Fill the 'New password' field", data: { value: newPassword } },
    { description: "Fill the 'Confirm new password' field", data: { value: newPassword } },
    { description: "Click the 'Update password' button" },
  ],
});
</code></pre>
<p>After <code>runSteps</code>, plain Playwright verifies the <strong>old password no longer signs in</strong> and the <strong>new password reaches</strong> <code>/dashboard</code> <strong>or</strong> <code>/onboarding</code>. Passmark proves the UI flow worked. Playwright proves the password actually changed in the database. Each layer does the part it is good at.</p>
<img alt="Forgot-password form: email field with 'Send code' submit" />

<img alt="Reset-password form: 6-digit OTP input with new password and confirm fields" style="display:block;margin-left:auto" />

<h3>Bug #3 — Login form occasionally submitted as GET with credentials in the URL</h3>
<p>While running the realtime test (two browser contexts in parallel), one context kept landing on <code>/login?email=...&amp;password=...</code> instead of <code>/dashboard</code>. The form submitted <em>before</em> React hydration replaced it with the controlled SPA version, so the browser used the default GET action.</p>
<p>Symptom:</p>
<pre><code class="language-plaintext">Received string: "http://127.0.0.1:3000/login?email=...&amp;password=new-h6fj9zie"
</code></pre>
<p>I had never seen this in single-context runs. Two contexts hammering the preview server slowed hydration past my 2000 ms blanket sleep, and the failure surfaced.</p>
<p>Fix in <code>e2e/passmark/helpers.ts</code>:</p>
<pre><code class="language-ts">await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle" });
const emailField = page.getByLabel("Email");
await emailField.waitFor({ state: "visible", timeout: 15_000 });
await page.waitForTimeout(1500); // give the controlled inputs a beat to mount
await emailField.fill(user.email);
</code></pre>
<p>Lesson: a blanket <code>waitForTimeout</code> is not enough for SSR'd SPA forms. Wait for the <em>element</em> state, not for the clock.</p>
<h2>5. Things I changed in the product because of the suite</h2>
<p>Not bugs in the strict sense. Gaps Passmark exposed while it tried to read the page like a user.</p>
<ul>
<li><p><code>&lt;InterestTagSelector /&gt;</code> was missing <code>aria-pressed</code> on its chips. Only the lobby had it. Added it everywhere; Passmark and screen readers now agree on which chips are selected.</p>
</li>
<li><p>Chat header, game card, and dashboard avatar got explicit <code>aria-label</code>s after Passmark complained that "Stranger" by itself was ambiguous.</p>
</li>
<li><p>The onboarding DOB picker now uses shadcn's <code>captionLayout="dropdown"</code> and only offers years where the user would be 18+. Previously the test could land on a current-year date and skip the rule.</p>
</li>
<li><p>Settings → Save preferences must update the lobby's "Your vibe" card. Passmark caught two cases where a stale React Query cache made the save look successful but the card did not update.</p>
</li>
</ul>
<h2>6. Surprises along the way</h2>
<ul>
<li><p><strong>Snapshot AI assertions race against fast redirects.</strong> My OTP reset originally redirected after 1500 ms. Passmark's first AI check takes around 22 s, so the success alert was already gone by the time it looked. I bumped the redirect to 3500 ms and dropped <code>waitUntil</code> so a Playwright assertion runs immediately after <code>runSteps</code>. Either Passmark or <code>setTimeout</code> owns the wait. Not both.</p>
</li>
<li><p><strong>Two-browser tests were easier than I expected.</strong> <code>runSteps</code> is per-page, so two contexts means two parallel Passmark drivers via <code>Promise.all</code>. The hard part is making sure both browsers reach a stable state before assertions run, not anything AI-specific.</p>
</li>
<li><p><strong>The pinned model rule again.</strong> Without <code>google/gemini-2.5-flash</code> set across every role, one OpenRouter route would occasionally pick a model that produced 400s on the same prompt. Pinning made the suite reproducible across days, which is what you want from a regression suite.</p>
</li>
</ul>
<h2>7. How to run this yourself</h2>
<p>You can take Lume for a spin in three ways: a <strong>live demo</strong>, a <strong>local run with seeded users</strong> (the fastest path), or <strong>the full Passmark suite</strong> to reproduce the regressions.</p>
<h3>Option A — Try the live demo (90 seconds)</h3>
<ol>
<li><p>Open <a href="https://lume-roan.vercel.app">https://lume-roan.vercel.app</a> in two browser windows (regular + incognito works fine).</p>
</li>
<li><p>Sign up two accounts with different emails. Confirmation is off on the demo; you go straight into onboarding.</p>
</li>
<li><p>Complete onboarding for both. Pick at least one overlapping interest (for example, both pick <code>Music</code>).</p>
</li>
<li><p>Click <strong>Start matching</strong> in both lobbies. You will be paired into a chat within a few seconds.</p>
</li>
<li><p>Walk through the flows: send messages, open the <strong>Games</strong> drawer and start Tic Tac Toe, try <strong>Report</strong>, try <strong>Block</strong>.</p>
</li>
</ol>
<h3>Option B — Run locally with the seeded users (recommended for review)</h3>
<p>Prereqs: Node 20+, <a href="https://supabase.com/docs/guides/cli">Supabase CLI</a>, Docker (for the local Supabase stack).</p>
<pre><code class="language-bash"># Clone and install
git clone https://github.com/hempun10/lume.git
cd lume
npm install

# Start the local Supabase stack (Postgres + Auth + Realtime + Mailpit at :54324)
npm run db:start

# Reset the schema, regenerate types, and seed Alice + Bob
npm run db:reset

# Start the dev server on http://localhost:3000
npm run dev
</code></pre>
<p>The seed creates two accounts you can log in as immediately, no signup needed:</p>
<table>
<thead>
<tr>
<th>Display name</th>
<th>Email</th>
<th>Password</th>
<th>Interests</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Alice</strong></td>
<td><code>user-a@example.com</code></td>
<td><code>password123</code></td>
<td>Music, Travel, Photography, Cooking</td>
</tr>
<tr>
<td><strong>Bob</strong></td>
<td><code>user-b@example.com</code></td>
<td><code>password123</code></td>
<td>Music, Cooking, Anime, Fitness</td>
</tr>
</tbody></table>
<p>The two overlapping interests (<code>Music</code>, <code>Cooking</code>) are what the shared-interests banner from Bug #1 picks up.</p>
<p><strong>Manual flow to exercise everything:</strong></p>
<ol>
<li><p>Open <code>http://localhost:3000</code> in two browsers (Chrome regular + Chrome incognito, or Chrome + Firefox).</p>
</li>
<li><p>Log in as <strong>Alice</strong> in window 1 and <strong>Bob</strong> in window 2 via <code>/login</code>.</p>
</li>
<li><p>In both windows, click <strong>Start matching</strong>. They should match within a few seconds.</p>
</li>
<li><p>In Alice's chat, confirm the <strong>"You both like Music · Cooking"</strong> banner is rendered. This is the Bug #1 regression check.</p>
</li>
<li><p>Send a few messages back and forth.</p>
</li>
<li><p>Open the <strong>Games</strong> drawer in either window, pick <strong>Tic Tac Toe</strong>, play a round. Both windows should sync moves over Supabase Broadcast.</p>
</li>
<li><p>From either side, hit <strong>Report</strong>, choose a reason, tick <strong>Also block</strong>, confirm. The pair is now excluded from future matching.</p>
</li>
<li><p>Sign out Alice, click <strong>Forgot password</strong> on <code>/login</code>, send the OTP, then open Mailpit at <code>http://127.0.0.1:54324</code> and copy the 6-digit code. Reset her password and log in with the new one. This is the Bug #2 regression check.</p>
</li>
</ol>
<h3>Option C — Run the Passmark regression suite</h3>
<p>Set <code>OPENROUTER_API_KEY</code> in <code>.env.local</code> (see <code>.env.example</code>), then use the package.json scripts:</p>
<pre><code class="language-bash"># Smoke test (landing only — fastest sanity check on the AI driver)
npm run test:e2e:smoke

# Full chromium suite (~31 tests, ~6–7 min after a fresh db:reset)
npm run test:e2e

# Same suite, headed (watch the AI clicks happen)
npm run test:e2e:headed

# Show the last HTML report
npm run test:e2e:report

# Realtime two-browser test (opt-in)
RUN_REALTIME_PASSMARK=1 npx playwright test e2e/passmark/realtime-matchmaking.spec.ts
</code></pre>
<p>The recovery and matchmaking specs need the local Supabase stack running (<code>npm run db:start</code>). Everything else can target the Vercel demo with <code>PLAYWRIGHT_BASE_URL=https://lume-roan.vercel.app npm run test:e2e</code>.</p>
<h2>8. Final results</h2>
<pre><code class="language-txt"># Default chromium suite (no realtime gating)
npm run test:e2e
→ ~31 tests, ~6–7 minutes after `npm run db:reset`

# Realtime opt-in
RUN_REALTIME_PASSMARK=1 npx playwright test e2e/passmark/realtime-matchmaking.spec.ts
→ 1 passed (1.5 min)

# Recovery
npx playwright test e2e/passmark/auth-recovery.spec.ts
→ 1 passed (44 s)
</code></pre>
<p>Three real bugs caught and fixed during the hackathon:</p>
<ul>
<li><p>A missing RLS policy that silently degraded every chat's prompt cards (Bug #1).</p>
</li>
<li><p>A magic-link recovery flow that only worked on the manual happy path (Bug #2).</p>
</li>
<li><p>A hydration race that occasionally leaked credentials into the URL (Bug #3).</p>
</li>
</ul>
<p>Plus a handful of accessibility and state-sync gaps the suite picked up along the way (section 6).</p>
<h2>9. Closing thought</h2>
<p>Selectors describe HTML. Passmark steps describe what the user is <em>trying to do</em>. For a vibe-coded realtime app where most of the surface area is state machines and ephemeral data, the second framing maps onto the actual product risk.</p>
<p>The bugs I found were not "this button is missing." They were "this feature looks fine but is silently degraded." That is the class of bug a screenshot-and-prose AI judge is shaped to find, and it is also the class of bug you ship the most of when you let a model write half the code with you.</p>
<p>If you are building anything with auth, realtime, and ephemeral state, give Passmark a weekend. The catch rate is real.</p>
<hr />
<h2>Links</h2>
<ul>
<li><p>Live demo: <a href="https://lume-roan.vercel.app">https://lume-roan.vercel.app</a></p>
</li>
<li><p>Repo: <a href="https://github.com/hempun10/lume">https://github.com/hempun10/lume</a></p>
</li>
</ul>
]]></content:encoded></item></channel></rss>