<?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: Ahsan Ahmad</title>
    <description>The latest articles on DEV Community by Ahsan Ahmad (@ahsanahmad).</description>
    <link>https://dev.to/ahsanahmad</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%2F1913043%2F78037f56-9461-4d0f-b5e7-6678d696891f.jpg</url>
      <title>DEV Community: Ahsan Ahmad</title>
      <link>https://dev.to/ahsanahmad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahsanahmad"/>
    <language>en</language>
    <item>
      <title>Setting up Playwright with Django: a real end-to-end tutorial</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Tue, 13 Oct 2026 07:37:08 +0000</pubDate>
      <link>https://dev.to/vanillapm/setting-up-playwright-with-django-a-real-end-to-end-tutorial-p9n</link>
      <guid>https://dev.to/vanillapm/setting-up-playwright-with-django-a-real-end-to-end-tutorial-p9n</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/vanillapm"&gt;Part 1&lt;/a&gt; I argued that Django's test client can return a perfect &lt;code&gt;200&lt;/code&gt; while the rendered page is quietly broken — because it never runs the JavaScript. Playwright is how you close that gap: it drives a real browser against your real app.&lt;/p&gt;

&lt;p&gt;This post is the hands-on setup. By the end you'll have Playwright running against a &lt;strong&gt;live Django instance&lt;/strong&gt;, logging in &lt;strong&gt;once&lt;/strong&gt; instead of on every test, using selectors that don't rot, and running green in &lt;strong&gt;CI&lt;/strong&gt;. Every snippet here is real, runnable code — I drive &lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;VanillaPM&lt;/a&gt; (a large Django app, 2,200+ tests) with Playwright daily, and this is the setup I'd hand any Django team.&lt;/p&gt;

&lt;p&gt;We'll go in order: &lt;strong&gt;install → the live-server problem → your first real test → logging in once → durable selectors → debugging → CI → the gotchas that cost me hours.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install
&lt;/h2&gt;

&lt;p&gt;Two layers, and people forget the second one. First the Python package, then the actual browser binaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pytest-playwright      &lt;span class="c"&gt;# the Python API + pytest fixtures&lt;/span&gt;
playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium        &lt;span class="c"&gt;# downloads the Chromium binary (~150MB)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pip install&lt;/code&gt; gives you the library; &lt;code&gt;playwright install&lt;/code&gt; downloads the browser Playwright drives. On a fresh CI runner you also need the system libraries — more on that in §7.&lt;/p&gt;

&lt;p&gt;Put both in your dev requirements so the team and CI stay in sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# requirements-dev.txt
pytest-playwright==0.5.2
pytest-django==4.9.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The one thing that trips everyone up: a live server
&lt;/h2&gt;

&lt;p&gt;Your normal Django tests call views &lt;strong&gt;in-process&lt;/strong&gt; through the test client. Playwright can't use that — it's a real browser, it needs a real &lt;strong&gt;URL&lt;/strong&gt; to navigate to. So you need Django to actually &lt;em&gt;serve&lt;/em&gt; your app during the test.&lt;/p&gt;

&lt;p&gt;Django ships exactly this: &lt;strong&gt;&lt;code&gt;StaticLiveServerTestCase&lt;/code&gt;&lt;/strong&gt;. It starts a real HTTP server on a background thread &lt;em&gt;and&lt;/em&gt; serves your static files (plain &lt;code&gt;LiveServerTestCase&lt;/code&gt; doesn't serve static files — use the &lt;code&gt;Static&lt;/code&gt; one or your CSS/JS 404s and half your selectors break).&lt;/p&gt;

&lt;p&gt;Here's the subtle part that explains a whole class of "why can't the browser see my data" bugs: &lt;strong&gt;&lt;code&gt;LiveServerTestCase&lt;/code&gt; is built on &lt;code&gt;TransactionTestCase&lt;/code&gt;, not &lt;code&gt;TestCase&lt;/code&gt;.&lt;/strong&gt; A normal &lt;code&gt;TestCase&lt;/code&gt; wraps each test in a transaction and rolls it back — fast, but the data is never &lt;em&gt;committed&lt;/em&gt;. The live server runs on a &lt;strong&gt;different thread with its own DB connection&lt;/strong&gt;, so it would never see uncommitted rows. &lt;code&gt;TransactionTestCase&lt;/code&gt; commits (and truncates tables between tests instead), which is slower but is the only way the browser-facing server can see the objects you create in the test. Keep that in mind — it's the root of the #1 gotcha in §8.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Your first real test
&lt;/h2&gt;

&lt;p&gt;Two ways to wire this up. I'll show both because teams split on pytest vs Django's &lt;code&gt;unittest&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Django's native way (&lt;code&gt;unittest&lt;/code&gt; + raw Playwright)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_e2e.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.staticfiles.testing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StaticLiveServerTestCase&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HomepageTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StaticLiveServerTestCase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setUpClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setUpClass&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;playwright&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tearDownClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;tearDownClass&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_homepage_shows_the_hero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;live_server_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heading&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to_be_visible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;self.live_server_url&lt;/code&gt; is handed to you by &lt;code&gt;LiveServerTestCase&lt;/code&gt; — it's the real &lt;code&gt;http://localhost:&amp;lt;random-port&amp;gt;&lt;/code&gt; the server is on. Run it with &lt;code&gt;python manage.py test&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pytest way (&lt;code&gt;pytest-playwright&lt;/code&gt; + &lt;code&gt;pytest-django&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;This is what I'd pick for a real suite — you get the &lt;code&gt;page&lt;/code&gt; fixture, &lt;code&gt;--headed&lt;/code&gt;, &lt;code&gt;--browser firefox&lt;/code&gt;, and automatic screenshots-on-failure for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_e2e.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.django_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# note: transaction=True — see below
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_homepage_shows_the_hero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heading&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to_be_visible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;live_server&lt;/code&gt; (from &lt;code&gt;pytest-django&lt;/code&gt;) is the fixture equivalent of &lt;code&gt;StaticLiveServerTestCase&lt;/code&gt;; &lt;code&gt;page&lt;/code&gt; (from &lt;code&gt;pytest-playwright&lt;/code&gt;) is a fresh browser page per test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;transaction=True&lt;/code&gt; is not optional here.&lt;/strong&gt; Plain &lt;code&gt;@pytest.mark.django_db&lt;/code&gt; wraps the test in a rolled-back transaction — and, exactly as in §2, the live server's thread won't see that uncommitted data. The moment a test &lt;em&gt;creates&lt;/em&gt; something the browser needs to see (a user, a project, a row), pair &lt;code&gt;live_server&lt;/code&gt; with &lt;code&gt;transaction=True&lt;/code&gt; (or the &lt;code&gt;transactional_db&lt;/code&gt; fixture). Forget it and you'll chase a phantom "the page says 0 results but I just created three."&lt;/p&gt;

&lt;p&gt;Notice there are &lt;strong&gt;no &lt;code&gt;sleep()&lt;/code&gt; calls&lt;/strong&gt; and no manual waits. &lt;code&gt;expect(locator).to_be_visible()&lt;/code&gt; is a &lt;em&gt;web-first assertion&lt;/em&gt;: it polls and retries for a few seconds until the condition holds (or fails the test). This auto-waiting is most of what makes Playwright stable where Selenium was flaky — lean on it, never on &lt;code&gt;time.sleep&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Log in once, not on every test
&lt;/h2&gt;

&lt;p&gt;Logging in through the UI in every test is the single biggest waste of time in an E2E suite — a 400ms form round-trip × 80 tests is real minutes, every run. Two ways to fix it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A — log in through the real UI
&lt;/h3&gt;

&lt;p&gt;A small fixture that drives the actual login form. Use it in &lt;strong&gt;one&lt;/strong&gt; test to prove login itself works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# conftest.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ui_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/accounts/login/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Log in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# wait for the redirect
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_login&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If you want to reuse an authenticated session across &lt;em&gt;runs&lt;/em&gt;, save &lt;code&gt;context.storage_state(path="auth.json")&lt;/code&gt; after logging in and pass &lt;code&gt;storage_state="auth.json"&lt;/code&gt; to a new context. One caveat worth knowing: &lt;code&gt;live_server&lt;/code&gt;'s port changes between runs, so cookies pinned to a URL won't carry over unless you fix the port — which is exactly why, for in-suite reuse, I reach for Option B instead.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B — skip the UI entirely (fastest)
&lt;/h3&gt;

&lt;p&gt;Most tests don't need to &lt;em&gt;re-test login&lt;/em&gt; — they need to be &lt;em&gt;logged in&lt;/em&gt;. Django's test &lt;code&gt;Client.force_login()&lt;/code&gt; mints a real session; grab its cookie and inject it straight into the browser context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# conftest.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.conf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.test&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return a helper that logs a given user into the browser context — no UI.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;force_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# creates the session server-side
&lt;/span&gt;        &lt;span class="n"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_NAME&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_cookies&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_login&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.django_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_dashboard_lists_my_projects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;django_user_model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;django_user_model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a@b.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... create a couple of projects owned by `user` ...
&lt;/span&gt;    &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;live_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Coral Ridge Resort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to_be_visible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;force_login&lt;/code&gt; is the same mechanism Django's own auth uses, so the session is genuinely valid — the browser sends the &lt;code&gt;sessionid&lt;/code&gt;, the live server reads it (committed, because &lt;code&gt;transaction=True&lt;/code&gt;), and you're authenticated without rendering a single login form. &lt;strong&gt;Rule of thumb:&lt;/strong&gt; one test that logs in through the UI (Option A) to prove login works; every other test uses Option B.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Selectors that survive a refactor
&lt;/h2&gt;

&lt;p&gt;Brittle selectors are why people abandon E2E suites — a designer renames a class and 40 tests go red. The fix is to select the way a &lt;em&gt;user&lt;/em&gt; finds things, not the way the DOM is nested. Playwright's locators are built for this, in rough order of preference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Submit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# best — role + accessible name
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# form fields by their &amp;lt;label&amp;gt;
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ready for approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# visible copy
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_placeholder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Search projects…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_test_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project-row&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# the escape hatch: data-testid
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reserve raw CSS/XPath (&lt;code&gt;page.locator(".pce-sec-head &amp;gt; div:nth-child(2)")&lt;/code&gt;) for when nothing else works — it's the first thing to break. For the genuinely hard cases, add a stable &lt;code&gt;data-testid&lt;/code&gt; to the template and select on that; it's an explicit contract with your tests that a restyle won't void.&lt;/p&gt;

&lt;p&gt;A concrete before/after from my own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ❌ brittle — tied to layout and order
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;div.toolbar &amp;gt; button:nth-of-type(3)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# ✅ durable — tied to meaning
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Save&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And always assert with the web-first &lt;code&gt;expect&lt;/code&gt;, which retries, rather than reading state once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ❌ reads once, races the UI
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;inner_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# ✅ retries until true (or fails)
&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_test_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to_have_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Debugging: the tools that save the afternoon
&lt;/h2&gt;

&lt;p&gt;When a test fails and you can't see why, you have better options than print-debugging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest &lt;span class="nt"&gt;--headed&lt;/span&gt; &lt;span class="nt"&gt;--slowmo&lt;/span&gt; 400          &lt;span class="c"&gt;# watch it run, slowed down&lt;/span&gt;
&lt;span class="nv"&gt;PWDEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 pytest &lt;span class="nt"&gt;-k&lt;/span&gt; test_dashboard     &lt;span class="c"&gt;# open Playwright Inspector — step through, pick selectors&lt;/span&gt;
playwright codegen http://localhost:8000   &lt;span class="c"&gt;# RECORD a session → it writes the selector code for you&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;playwright codegen&lt;/code&gt; is the quiet superpower for Django devs: open your running app, click through the flow by hand, and Playwright prints working locator code you paste into a test. And for the failures that only happen in CI, turn on &lt;strong&gt;tracing&lt;/strong&gt; and replay them frame by frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest &lt;span class="nt"&gt;--tracing&lt;/span&gt; retain-on-failure      &lt;span class="c"&gt;# pytest-playwright writes a trace.zip on failures&lt;/span&gt;
playwright show-trace test-results/.../trace.zip   &lt;span class="c"&gt;# a full timeline: DOM snapshots, network, console&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trace viewer is the difference between "it's flaky on CI, no idea why" and watching the exact moment it went wrong, with the DOM state at every step.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Running it in CI (GitHub Actions)
&lt;/h2&gt;

&lt;p&gt;Three CI-specific things matter: install the &lt;strong&gt;system libraries&lt;/strong&gt; the browser needs (&lt;code&gt;--with-deps&lt;/code&gt;), give Django a &lt;strong&gt;real database&lt;/strong&gt; (a Postgres service), and keep these slow tests on their &lt;strong&gt;own job&lt;/strong&gt; so they don't gate every unit-test run.&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="c1"&gt;# .github/workflows/e2e.yml&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;e2e&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;e2e&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&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;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
          &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
          &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test_db&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
          &lt;span class="s"&gt;--health-cmd pg_isready --health-interval 10s&lt;/span&gt;
          &lt;span class="s"&gt;--health-timeout 5s --health-retries 5&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.12"&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;pip install -r requirements-dev.txt&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;playwright install --with-deps chromium&lt;/span&gt;    &lt;span class="c1"&gt;# --with-deps = the system libs&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;pytest -m e2e --tracing retain-on-failure&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;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://postgres:postgres@localhost:5432/test_db&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;failure()&lt;/span&gt;
        &lt;span class="na"&gt;with&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;playwright-traces&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test-results/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--with-deps&lt;/code&gt; is the line people miss — on a bare Ubuntu runner Chromium won't launch without a set of shared libraries, and this installs them. Uploading the traces on failure means a red CI run hands you a replayable recording, not just a stack trace. (Mark your browser tests with &lt;code&gt;@pytest.mark.e2e&lt;/code&gt; and register the marker in &lt;code&gt;pytest.ini&lt;/code&gt; so &lt;code&gt;pytest -m e2e&lt;/code&gt; picks exactly them.)&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The gotchas that cost me hours
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"The browser sees 0 rows but I created 3."&lt;/strong&gt; The transactional-DB issue from §2/§3. Use &lt;code&gt;@pytest.mark.django_db(transaction=True)&lt;/code&gt; (or &lt;code&gt;transactional_db&lt;/code&gt;) with &lt;code&gt;live_server&lt;/code&gt;. This is #1 by a distance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static files 404 / unstyled page / missing selectors.&lt;/strong&gt; You used &lt;code&gt;LiveServerTestCase&lt;/code&gt; instead of &lt;code&gt;StaticLiveServerTestCase&lt;/code&gt;, or you're testing against &lt;code&gt;runserver&lt;/code&gt; with &lt;code&gt;DEBUG=False&lt;/code&gt; and no &lt;code&gt;collectstatic&lt;/code&gt;. Serve the statics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sync_playwright&lt;/code&gt; + async errors&lt;/strong&gt; ("It looks like you are using Playwright Sync API inside the asyncio loop"). Something in your stack is running an event loop. Keep the sync API out of async contexts, or switch to the async API throughout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flakiness from manual waits.&lt;/strong&gt; Every &lt;code&gt;time.sleep(2)&lt;/code&gt; is a future flake. Delete them and trust &lt;code&gt;expect(...)&lt;/code&gt; auto-waiting and &lt;code&gt;page.wait_for_url&lt;/code&gt; / &lt;code&gt;wait_for_load_state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow suite.&lt;/strong&gt; E2E is slow by nature — keep it small (see below), reuse auth (§4), run it as its own CI job, and run the fast unit tests on every push.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to test with this (and what not to)
&lt;/h2&gt;

&lt;p&gt;Resist testing &lt;em&gt;everything&lt;/em&gt; through the browser — a huge E2E suite is slow and flaky and the team learns to ignore it. Keep the base of your pyramid wide with fast unit tests (VanillaPM has 2,200+ of those), and spend Playwright on a &lt;strong&gt;small set of critical journeys&lt;/strong&gt;: can a user log in, can they complete the one flow that must never break, does the JavaScript-heavy screen actually render. Reach for the browser only when the thing you need to verify &lt;em&gt;only exists in the browser&lt;/em&gt;.&lt;/p&gt;




&lt;p&gt;That's the whole setup — live server, auth reuse, durable selectors, debugging, CI, and the traps. It's the foundation the next two posts build on: &lt;strong&gt;Part 3&lt;/strong&gt; uses this same machinery to automate a couple hundred product screenshots, and &lt;strong&gt;Part 4&lt;/strong&gt; turns Playwright into a debugging instrument to hunt a CSS bug that had no visible element.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm building *&lt;/em&gt;&lt;a href="https://vanillapm.com/features/" rel="noopener noreferrer"&gt;VanillaPM&lt;/a&gt;** — a free, full-lifecycle PM platform — in Django, in the open. The rest of this &lt;strong&gt;Playwright for Django&lt;/strong&gt; series is here if build-in-public engineering is your thing.*&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>django</category>
      <category>testing</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your issue tracker is not an issue log</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Wed, 07 Oct 2026 19:09:56 +0000</pubDate>
      <link>https://dev.to/vanillapm/your-issue-tracker-is-not-an-issue-log-5b27</link>
      <guid>https://dev.to/vanillapm/your-issue-tracker-is-not-an-issue-log-5b27</guid>
      <description>&lt;p&gt;I spent years as a developer with a tidy issue tracker and a recurring, stupid problem: the things that actually derailed our projects were almost never in it.&lt;/p&gt;

&lt;p&gt;The database migration that needed a DBA who was on leave. The client contact who stopped replying for three weeks. The staging environment nobody owned. None of these were tickets. All of them cost us more than any bug we shipped.&lt;/p&gt;

&lt;p&gt;I only got a clean vocabulary for this when I started studying project management properly. The distinction turns out to be small, old, and genuinely useful — so I want to hand it over without the certification padding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction: an issue is not a risk
&lt;/h2&gt;

&lt;p&gt;Here is the whole thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;risk&lt;/strong&gt; is a problem that &lt;em&gt;might&lt;/em&gt; happen. Future, probabilistic.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;issue&lt;/strong&gt; is a problem that &lt;em&gt;is&lt;/em&gt; happening. Present, certain.&lt;/li&gt;
&lt;li&gt;When a risk materialises, it stops being a risk and becomes an issue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the useful part. Risks are not a list you write once at kickoff and never revisit — they are supposed to &lt;em&gt;graduate&lt;/em&gt;. "The lift supplier might get held up at customs" is a risk. The morning the lifts are actually sitting in a customs warehouse, it is an issue, and it needs a different response: not monitoring, but an owner and an action today.&lt;/p&gt;

&lt;p&gt;Most teams I have seen have no mechanism for that transition. The risk list lives in a document from month one. The issue nobody saw coming lands in standup as a surprise. It was not a surprise — it was on page two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your tracker can't hold this
&lt;/h2&gt;

&lt;p&gt;Nothing stops you putting "lifts stuck at customs" in Jira. People do. It is just that the tracker is already doing a different job, and the two jobs fight.&lt;/p&gt;

&lt;p&gt;Your tracker holds &lt;strong&gt;work you intend to do&lt;/strong&gt;. Issues are &lt;strong&gt;problems blocking work you already planned&lt;/strong&gt;. Mixed into one backlog:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The blocker gets a priority label and sits in a column with forty other things.&lt;/li&gt;
&lt;li&gt;It has an assignee — but the assignee is usually whoever will &lt;em&gt;do the work after the blocker clears&lt;/em&gt;, not the person who can clear it. Those are frequently different people, and often the blocker-clearer is not an engineer at all.&lt;/li&gt;
&lt;li&gt;It has no escalation path. A ticket's escalation is "move it up the backlog". A real blocker's escalation is "this needs the sponsor to make a call by Thursday".&lt;/li&gt;
&lt;li&gt;It closes when the code merges. But most impediments are cleared by a conversation, a decision, or a payment — nothing merges, so nothing closes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mismatch is not about tooling. It is that one of these is a work queue and the other is a blocker list, and a blocker list is read by different people, at a different cadence, with different authority to act.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAID, in ninety seconds
&lt;/h2&gt;

&lt;p&gt;The frame this comes from is &lt;strong&gt;RAID&lt;/strong&gt; — four registers, kept separately because they get different treatment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What you do with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;R&lt;/strong&gt;isks&lt;/td&gt;
&lt;td&gt;Might happen&lt;/td&gt;
&lt;td&gt;Monitor; plan a response in advance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A&lt;/strong&gt;ssumptions&lt;/td&gt;
&lt;td&gt;Believed true, unverified&lt;/td&gt;
&lt;td&gt;Validate before it bites you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;I&lt;/strong&gt;ssues&lt;/td&gt;
&lt;td&gt;Happening now&lt;/td&gt;
&lt;td&gt;Own it, date it, drive it closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;D&lt;/strong&gt;ependencies&lt;/td&gt;
&lt;td&gt;Someone else's work gates yours&lt;/td&gt;
&lt;td&gt;Track the other party, not your own team&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It looks like bureaucracy. In practice, for a team of any size, it is four lists and a weekly ten minutes. The value is almost entirely in the &lt;em&gt;separation&lt;/em&gt; — assumptions in particular, because an unexamined assumption is the single most common way a plan quietly becomes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes in practice
&lt;/h2&gt;

&lt;p&gt;Four things, and they are cheap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One named owner per issue — a person, not a team.&lt;/strong&gt; "Platform team" owns nothing. Unowned issues drift, and drift is the default state of a blocker that is inconvenient for everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A date, not a priority.&lt;/strong&gt; Priorities inflate until everything is P1. A date is falsifiable: Thursday arrives and either it moved or it did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An explicit escalation path.&lt;/strong&gt; Before you need it. "If this is not cleared by Thursday, it goes to X." Most blockers persist not because they are hard but because nobody knew who was allowed to decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review and close, in a fixed cadence.&lt;/strong&gt; The failure mode of every issue log is becoming a graveyard — forty open entries, half resolved months ago, nobody trusting it enough to read it. A log nobody trusts is worse than no log, because it creates the feeling of control without any.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that reframed the job for me
&lt;/h2&gt;

&lt;p&gt;In the PM literature this task is not called "track issues". It is called &lt;strong&gt;remove impediments&lt;/strong&gt; — and the framing is deliberate. Tracking is bookkeeping. The job is clearing the path in front of your team.&lt;/p&gt;

&lt;p&gt;That reads as soft-skills fluff until you notice it changes what you do on a Tuesday. Logging "waiting on legal review" is bookkeeping. Walking to legal, finding out the review is stuck on a question nobody asked them, and answering it — that is the job. The log exists to make sure you &lt;em&gt;know&lt;/em&gt; which path to clear, not to be the work itself.&lt;/p&gt;

&lt;p&gt;For anyone moving from senior dev toward lead or EM: this is a large part of what the role actually is, and it is nearly invisible from the IC seat. It looks like someone going to a lot of meetings. Sometimes it is. Sometimes it is the only reason your sprint finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the longer version
&lt;/h2&gt;

&lt;p&gt;I have been writing up the PMP exam content as practical lessons while studying it, with worked examples — &lt;a href="https://vanillapm.com/learn/pmp/manage-issues/" rel="noopener noreferrer"&gt;the one on issue logs and removing impediments is here&lt;/a&gt;. It covers the risk→issue escalation path in more detail, and what the exam expects you to do when a blocker appears (short version: act to clear it, don't just log it).&lt;/p&gt;

&lt;p&gt;Full disclosure: I build &lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;VanillaPM&lt;/a&gt;, an open project management tool, and the lessons are part of it. They are free and ungated — I wrote them to learn the material myself, and publishing them seemed better than letting the notes rot in a folder.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The one thing worth stealing from this post&lt;/strong&gt;, if you take nothing else: separate the problems that &lt;em&gt;are&lt;/em&gt; happening from the work you &lt;em&gt;intend&lt;/em&gt; to do. They need different owners, different cadences, and different people reading them. One backlog cannot do both jobs, and the one it quietly stops doing is the one that sinks projects.&lt;/p&gt;

</description>
      <category>management</category>
      <category>productivity</category>
      <category>career</category>
      <category>agile</category>
    </item>
    <item>
      <title>What is Playwright — and why it belongs in your Django toolkit</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:13:59 +0000</pubDate>
      <link>https://dev.to/vanillapm/what-is-playwright-and-why-it-belongs-in-your-django-toolkit-2bk7</link>
      <guid>https://dev.to/vanillapm/what-is-playwright-and-why-it-belongs-in-your-django-toolkit-2bk7</guid>
      <description>&lt;p&gt;Here's a scene every team knows. A change ships. The tests are green. The server returns &lt;code&gt;200&lt;/code&gt;. And then a customer emails a screenshot: the "Submit" button does nothing on their phone, and it's been broken for three days.&lt;/p&gt;

&lt;p&gt;Nothing &lt;em&gt;failed&lt;/em&gt;. The unit tests checked the functions. The API returned the right JSON. But no machine ever did the one thing that matters — &lt;strong&gt;open the page in a real browser and try to use it.&lt;/strong&gt; That gap is exactly what Playwright fills, and it's the reason it's become the most useful tool in my Django kit that isn't Django itself.&lt;/p&gt;

&lt;p&gt;This is the first of a four-part series. Today is the on-ramp: &lt;em&gt;what Playwright is, why it matters, and the surprising range of things it can do&lt;/em&gt; — written so a developer gets the depth &lt;strong&gt;and&lt;/strong&gt; the people who fund and lead the work get the point. (The next posts get hands-on: a full Django setup tutorial, then two real stories from building &lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;VanillaPM&lt;/a&gt; — automating a couple hundred product screenshots, and hunting a CSS bug that had no visible element.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What Playwright actually is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Playwright is a tool that drives a real web browser the way a person would&lt;/strong&gt; — it opens a page, clicks buttons, fills forms, waits for things to appear, and can then check what happened, take a screenshot, or measure the page. It's open source, made by Microsoft, and it speaks Python (and JavaScript, Java, and .NET).&lt;/p&gt;

&lt;p&gt;The whole idea fits in one snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;          &lt;span class="c1"&gt;# "Example Domain"
&lt;/span&gt;    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;it-works.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. You just ran a real Chromium browser, loaded a page, read its title, and saved a picture of it — from Python, in ten lines. Everything else is variations on that theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's necessary: the gap manual testing leaves
&lt;/h2&gt;

&lt;p&gt;Most test suites are made of &lt;strong&gt;unit tests&lt;/strong&gt; — they check a function returns the right value — and maybe &lt;strong&gt;API tests&lt;/strong&gt; — they check an endpoint returns the right JSON. Those are essential and fast, and you should have hundreds of them. (VanillaPM has over two thousand.)&lt;/p&gt;

&lt;p&gt;But none of them answer the question your users actually care about: &lt;em&gt;can a human open this page and get their job done?&lt;/em&gt; Modern Django apps make that gap wider, not smaller — the moment you sprinkle in Alpine, HTMX, or any JavaScript, the server can return a perfect &lt;code&gt;200&lt;/code&gt; while the rendered page is broken. Django's own test client doesn't run JavaScript; it never sees the bug.&lt;/p&gt;

&lt;p&gt;The traditional patch is &lt;strong&gt;manual QA&lt;/strong&gt; — a person clicks through the critical screens before each release. That works right up until it doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's &lt;strong&gt;slow and repetitive&lt;/strong&gt; — the same clicks, every release, forever.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;inconsistent&lt;/strong&gt; — humans get bored, skip steps, and test different things each time.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;doesn't scale&lt;/strong&gt; — ten features become a hundred, and no one can re-click everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Playwright turns that click-through into &lt;strong&gt;code that runs in seconds, the same way, every single time&lt;/strong&gt; — in your CI pipeline, before anything reaches a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  💼 For the people who sign off on the work
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;(A lane for business and policy leaders — skip ahead if you're here for the code.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Strip away the tooling and the pitch is simple: &lt;strong&gt;software that isn't tested by machines is tested by your customers.&lt;/strong&gt; Automated browser testing moves the moment you discover a broken screen from &lt;em&gt;"a user reported it"&lt;/em&gt; to &lt;em&gt;"the pipeline caught it before it shipped."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Why that matters where it counts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cost of a bug grows the later you find it.&lt;/strong&gt; Caught in CI, a broken checkout flow costs a developer a few minutes. Caught in production, it costs support tickets, an emergency release, and — the expensive one — trust. Automated checks shift that cost left, to its cheapest point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It lets a small team move fast &lt;em&gt;without&lt;/em&gt; breaking what already works.&lt;/strong&gt; This is the real unlock. One developer built VanillaPM — a fifty-module platform — and ships changes confidently because &lt;strong&gt;2,225 automated checks&lt;/strong&gt; run on every change and scream if anything regresses. That's not heroics; it's leverage. Automation is how few people safely maintain a lot of software.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It turns "done" into something you can verify.&lt;/strong&gt; For anyone commissioning software — including public-sector and regulated buyers — "the critical user journeys are covered by automated browser tests" is a concrete, auditable acceptance criterion. It's a line you can put in a contract and a vendor can prove, instead of taking "it works" on faith.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one-line version for a board slide: &lt;em&gt;automated testing is insurance against your own releases — a one-time build that pays out on every change, forever.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it can do (the part that surprises people)
&lt;/h2&gt;

&lt;p&gt;"Playwright is for testing" is true but undersells it badly. Because it can drive a real browser and read anything on the page, the same tool covers a remarkable spread of jobs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;End-to-end tests&lt;/strong&gt; — the headline use. Prove a real journey works, start to finish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_user_can_log_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/login/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button[type=submit]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heading&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;is_visible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Screenshots, on demand&lt;/strong&gt; — point it at any screen and capture a pixel-perfect image. Great for docs, marketing, and visual-regression checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dashboard.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;full_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PDF generation&lt;/strong&gt; — render any page straight to a PDF (headless Chromium):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scraping &amp;amp; data extraction&lt;/strong&gt; — read content from pages that only exist after JavaScript runs, which plain &lt;code&gt;requests&lt;/code&gt; + BeautifulSoup can't reach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;titles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h2.product-title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all_text_contents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cross-browser, from one script&lt;/strong&gt; — the same code runs on Chromium, Firefox, and WebKit (Safari's engine), so you catch the "works in Chrome, broken in Safari" class of bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;firefox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webkit&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# ... run the same checks ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mobile emulation&lt;/strong&gt; — load any page as a specific device, with the right viewport and touch behaviour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;iphone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iPhone 13&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;iphone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Debugging&lt;/strong&gt; — and this is the sleeper. Because you can run &lt;em&gt;arbitrary JavaScript in the page&lt;/em&gt; and read the result, Playwright becomes a measuring instrument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;() =&amp;gt; document.documentElement.scrollWidth - document.documentElement.clientWidth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line is how I pinned down a mobile layout bug that no human eye could isolate — the page scrolled sideways but &lt;em&gt;nothing on screen looked wrong&lt;/em&gt;. (That's the war story in Part 4 of this series.)&lt;/p&gt;

&lt;p&gt;In VanillaPM, Playwright does three of these jobs in production-adjacent anger: it runs the end-to-end smoke tests, it captures a couple hundred product screenshots for the investor deck and docs on command, and it's my go-to debugging probe. One tool, three very different jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Django especially
&lt;/h2&gt;

&lt;p&gt;Two reasons Playwright and Django are a natural pair:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The JavaScript gap is real in Django apps.&lt;/strong&gt; Django's test client is excellent and fast — but it's HTML-only. The moment your templates lean on Alpine or HTMX for interactivity, you need &lt;em&gt;something&lt;/em&gt; that executes that JavaScript to know the page actually works. Playwright is that something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django hands you a server to test against.&lt;/strong&gt; &lt;code&gt;StaticLiveServerTestCase&lt;/code&gt; spins up a real, running instance of your app inside the test — the perfect target for Playwright to drive. The two slot together with almost no glue. (That's the whole of Part 2.)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🧭 For engineering leaders
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;(A lane for the people deciding whether to adopt this.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A few framing calls worth making deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's the thin top of the testing pyramid, not a replacement for it.&lt;/strong&gt; End-to-end tests are powerful but slow and comparatively expensive to maintain. Keep the base of the pyramid wide (fast unit tests), and use Playwright for a &lt;em&gt;small, high-value set&lt;/em&gt; of critical journeys — login, checkout, the one flow that must never break. Teams that try to test &lt;em&gt;everything&lt;/em&gt; through the browser end up with a slow, flaky suite they learn to ignore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget honestly for flakiness.&lt;/strong&gt; Browser tests fail for boring reasons — a selector moves, a timing assumption breaks. (I recently spent an afternoon on a test that failed only on CI because of filesystem ordering — the flaky-test tax is real.) Playwright's auto-waiting removes most of it, but plan for some maintenance rather than pretending it's free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build-vs-buy math is friendly.&lt;/strong&gt; It's open source with no per-seat licensing, and &lt;em&gt;one&lt;/em&gt; tool covers three browser engines, mobile emulation, a recorder that writes tests for you (&lt;code&gt;playwright codegen&lt;/code&gt;), and a trace viewer that replays a failed run step by step. The adoption cost is genuinely low — which matters for a small team or a tight budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ROI is bigger than "tests."&lt;/strong&gt; The same capability de-risks refactors (change internals confidently behind a safety net), gates your CI, &lt;em&gt;and&lt;/em&gt; doubles as automation infrastructure — screenshots, PDFs, uptime checks. You buy one skill and get several capabilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest caveats
&lt;/h2&gt;

&lt;p&gt;This crowd rewards honesty, so: Playwright is not a silver bullet. End-to-end tests are slower than unit tests, they can be flaky if you lean on brittle selectors, and they need upkeep as the UI changes. The discipline is to use them &lt;em&gt;surgically&lt;/em&gt; — for the handful of journeys that genuinely matter — and let fast unit tests do the heavy lifting underneath. Reach for the browser when, and only when, the thing you need to verify only exists in the browser.&lt;/p&gt;

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

&lt;p&gt;That's the &lt;em&gt;why&lt;/em&gt;. The rest of this series gets concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 2 — the tutorial:&lt;/strong&gt; setting up Playwright with Django properly, start to finish (the &lt;code&gt;StaticLiveServerTestCase&lt;/code&gt; pattern, logging in once, selectors that don't rot, CI).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3 — automation:&lt;/strong&gt; how I automated a couple hundred product screenshots with one script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4 — debugging:&lt;/strong&gt; finding a CSS bug that had no visible element.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've been meaning to close the gap between "the tests pass" and "the page actually works," this is the tool that does it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write this series while building &lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;VanillaPM&lt;/a&gt; — a free, full-lifecycle project-management platform — in the open. Follow along for more build-in-public engineering.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Grounded AI: making an LLM draft a project charter from real data — without hallucinating</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:47:06 +0000</pubDate>
      <link>https://dev.to/vanillapm/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without-hallucinating-4mgl</link>
      <guid>https://dev.to/vanillapm/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without-hallucinating-4mgl</guid>
      <description>&lt;p&gt;"Add AI" is the easiest line item on any 2026 roadmap and the easiest way to ship something worse than nothing. Wire an LLM to a "Generate charter" button, feed it the project name, and it will happily produce a beautifully-worded project charter full of objectives, success criteria and stakeholders that &lt;strong&gt;do not exist&lt;/strong&gt;. In a governed project-management tool, a confident invention is worse than a blank field — someone might &lt;em&gt;believe&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;I wanted the generate button anyway, because there's a real version of it. Here's how I built a charter generator that drafts genuinely useful prose from the user's &lt;em&gt;actual&lt;/em&gt; data, and refuses to make things up — in Django, bring-your-own-key, no framework magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things this is not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Not autopopulate.&lt;/strong&gt; I already had a deterministic autopopulate: copy the sponsor from the project record, the dates from the schedule, and so on. That's great for &lt;em&gt;fields&lt;/em&gt;, but a charter is mostly &lt;em&gt;prose&lt;/em&gt; — a scope statement, an objectives section, a rationale — synthesised from the pre-project inputs (the business case, the benefits plan, the agreements). You can't &lt;code&gt;str()&lt;/code&gt; your way to a scope statement. Autopopulate structurally can't do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not a free-form LLM.&lt;/strong&gt; The other extreme — "here's the project name, write me a charter" — is exactly the hallucination machine. The model has no idea what your business case says, so it invents a plausible one.&lt;/p&gt;

&lt;p&gt;The useful thing lives in between: &lt;strong&gt;grounded synthesis.&lt;/strong&gt; Read the real input documents, and ask the model to &lt;em&gt;compose&lt;/em&gt; — using only what's actually there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: ground in the real inputs, not the model's imagination
&lt;/h2&gt;

&lt;p&gt;The whole trick is that the model's only source of truth is text &lt;em&gt;you&lt;/em&gt; supply from the user's own documents — never its training data.&lt;/p&gt;

&lt;p&gt;In VanillaPM every document type declares its &lt;strong&gt;input documents&lt;/strong&gt; (its ITTO prerequisites — the things that must exist before it). A charter's inputs are the business case, the benefits management plan, the agreements. So "grounding" is concrete: go read &lt;em&gt;those&lt;/em&gt; documents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DOC_CHAR_CAP&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8_000&lt;/span&gt;    &lt;span class="c1"&gt;# per input document
&lt;/span&gt;&lt;span class="n"&gt;TOTAL_CONTEXT_CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40_000&lt;/span&gt;  &lt;span class="c1"&gt;# the whole grounding context
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;input_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(type, title, text) for each declared prerequisite that exists and has
    readable text — each capped so one long doc can&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t dominate the context.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prereq&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;prerequisite_types&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# from the dependency graph
&lt;/span&gt;        &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;latest_of_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prereq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;document_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# walk the stored rich-text → plain text
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;prereq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;DOC_CHAR_CAP&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two boring-but-important details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cap per document &lt;em&gt;and&lt;/em&gt; in total.&lt;/strong&gt; One 200-page attachment shouldn't crowd out the other inputs (or blow your context window / bill). A per-doc cap plus a total cap keeps the grounding balanced and bounded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ground in &lt;em&gt;authored&lt;/em&gt; text, not the whole DB.&lt;/strong&gt; &lt;code&gt;document_text()&lt;/code&gt; walks the document's stored rich-text tree and pulls the prose the human actually wrote — not a JSON dump of every model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The grounding context is then just: the deterministic structured facts (reused from autopopulate) &lt;strong&gt;+&lt;/strong&gt; these input-document excerpts, under a big honest header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the system prompt does the anti-hallucination work
&lt;/h2&gt;

&lt;p&gt;This is the part people skip. The model will fabricate &lt;em&gt;unless you make refusing the easier path.&lt;/em&gt; Three rules, and the second one is the whole ballgame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You draft sections of a PMBOK-aligned project document.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1. Ground every section ONLY in the provided project data and input &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   documents below. They are your only source of truth.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2. If the data a section needs is missing, write ONE short professional &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   sentence stating what input is still needed — do NOT fabricate.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3. Return JSON only: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;section_key&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;prose&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}]}. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Use only the section keys given. No markdown headings, no preamble.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule 2 is the difference between a demo and a tool. Giving the model an &lt;strong&gt;acceptable way to say "I don't have this"&lt;/strong&gt; — a short "needs input: the benefits plan doesn't state a target ROI" placeholder — means it takes that exit instead of inventing a number. You're not just &lt;em&gt;asking&lt;/em&gt; it not to hallucinate; you're handing it a better move than hallucinating.&lt;/p&gt;

&lt;p&gt;The user turn wires the sections to draft onto the grounding context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;USER_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Draft these sections of the project&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s {doc_label}. Use each key exactly:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{targets}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=== PROJECT DATA AND INPUT DOCUMENTS (your only source of truth) ===&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{context}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Structured JSON out, fixed keys in.&lt;/strong&gt; The model returns &lt;code&gt;{"sections":[{"key", "body"}]}&lt;/code&gt; and may only use keys I gave it. That does two jobs: parsing is trivial and deterministic, and the model can't wander off and invent a "Section 12: Executive Bonus Plan." It fills the blanks I asked for, or it says it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: only touch the blanks, never the real data
&lt;/h2&gt;

&lt;p&gt;A charter is a mix of &lt;em&gt;authored&lt;/em&gt; prose sections and &lt;em&gt;live-data&lt;/em&gt; sections (a stakeholder table, a risk snapshot) that are generated from real records. The AI must never touch the latter — those aren't opinions to draft, they're facts to render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;target_sections&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overwrite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content_json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;section_has_datablock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# a live register/table snapshot
&lt;/span&gt;            &lt;span class="k"&gt;continue&lt;/span&gt;                           &lt;span class="c1"&gt;# generated from real data — hands off
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;section_has_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;overwrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;                           &lt;span class="c1"&gt;# non-destructive: only fill empty sections
&lt;/span&gt;        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attrs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attrs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the fill is &lt;strong&gt;non-destructive&lt;/strong&gt; (it never overwrites what a human wrote unless they ask) and it &lt;strong&gt;skips live-data sections entirely&lt;/strong&gt;. The model works on empty &lt;em&gt;prose&lt;/em&gt; sections and nothing else. Bonus: because the section keys/titles come from the document itself, the exact same code drafts a communications plan or a risk-management plan — it generalised for free.&lt;/p&gt;

&lt;p&gt;And the output lands in &lt;strong&gt;draft&lt;/strong&gt;, for a human to review and approve. The AI proposes; the PM decides. It's never a fact until a person signs off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: bring-your-own-key, and fail safe
&lt;/h2&gt;

&lt;p&gt;No forced AI markup, and no crash when AI isn't configured. The path resolves BYOK-first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_ai_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;byok_available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                     &lt;span class="c1"&gt;# user's own key
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;byok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;user_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;MODEL_BYOK&lt;/span&gt;     &lt;span class="c1"&gt;#   → free through us
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;managed_available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                  &lt;span class="c1"&gt;# metered credits
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;managed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;server_key&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;MODEL_MANAGED&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                           &lt;span class="c1"&gt;# feature cleanly disabled
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the SDK import is &lt;strong&gt;guarded&lt;/strong&gt; — a server without the &lt;code&gt;anthropic&lt;/code&gt; package (or a user without a key) degrades to a disabled button, never an exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
    &lt;span class="n"&gt;_AVAILABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ImportError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_AVAILABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the module holds a key; the caller passes the user's decrypted key in per request. (Load tests swap in an offline stub so they never hit the real API — cost and rate limits stay off the test path.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What grounding buys you — and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Grounding isn't a hallucination &lt;em&gt;cure&lt;/em&gt;; it's a hallucination &lt;em&gt;budget&lt;/em&gt;. What it reliably buys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model composes from &lt;strong&gt;your&lt;/strong&gt; business case, so the objectives are &lt;em&gt;your&lt;/em&gt; objectives.&lt;/li&gt;
&lt;li&gt;Missing inputs surface as honest "needs input" notes — which doubles as a checklist of what to go write.&lt;/li&gt;
&lt;li&gt;Structured, key-scoped output means no invented sections and trivial parsing.&lt;/li&gt;
&lt;li&gt;Non-destructive, draft-only, human-approved: the AI can't silently rewrite a governed document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it doesn't do: it won't fix a vague business case (garbage in, grounded-garbage out), and a determined model can still mis-synthesise. So it's a &lt;em&gt;draft&lt;/em&gt; generator with a human gate, not an autopilot — which is exactly what a governed document deserves.&lt;/p&gt;

&lt;p&gt;The pattern generalises well beyond charters: &lt;strong&gt;read the real inputs, cap and label them as the only source of truth, give the model an honest "I don't know," constrain the output shape, and never let it touch data it should only render.&lt;/strong&gt; That's most of the distance between "we added AI" and AI you can actually put near real work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building a free, full-lifecycle PM platform in the open — this generator ships in it (BYOK). If the build-in-public engineering is your thing, the rest of the series is here.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>django</category>
      <category>llm</category>
    </item>
    <item>
      <title>Keeping Alpine.js + Django templates sane in a large app</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Mon, 07 Sep 2026 10:33:22 +0000</pubDate>
      <link>https://dev.to/vanillapm/keeping-alpinejs-django-templates-sane-in-a-large-app-3424</link>
      <guid>https://dev.to/vanillapm/keeping-alpinejs-django-templates-sane-in-a-large-app-3424</guid>
      <description>&lt;p&gt;I built a big app the "boring" way: &lt;strong&gt;server-rendered Django templates + Alpine.js for interactivity, no SPA.&lt;/strong&gt; ~50 Django apps, a lot of screens, one developer. Alpine is the perfect amount of JavaScript for this — until the app gets large and you start hitting its sharp edges.&lt;/p&gt;

&lt;p&gt;Here are the patterns that kept it maintainable, and the gotchas that cost me real hours. All of it is stuff I wish someone had told me at line 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: islands, not an app
&lt;/h2&gt;

&lt;p&gt;The trap with Alpine in a big project is treating it like a mini-SPA — global stores everywhere, components reaching into each other. Don't. Treat each interactive region as an &lt;strong&gt;island&lt;/strong&gt;: a self-contained Alpine component hydrating one chunk of server-rendered HTML. The server owns the data and the page; Alpine owns &lt;em&gt;this widget's&lt;/em&gt; behaviour.&lt;/p&gt;

&lt;p&gt;Two rules fall out of that, and they're the difference between "fine at 10 screens" and "fine at 200."&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: register components — stop writing logic inline
&lt;/h2&gt;

&lt;p&gt;Inline &lt;code&gt;x-data="{ ... }"&lt;/code&gt; is lovely for a toggle. For anything real, it's a liability — and it has a &lt;em&gt;nasty&lt;/em&gt; failure mode.&lt;/p&gt;

&lt;p&gt;Put a literal double-quote inside an inline expression and you silently close the HTML attribute early:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ❌ Alpine sees x-data="{ label: "  — the rest becomes broken markup --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ label: "&lt;/span&gt;&lt;span class="na"&gt;Save&lt;/span&gt;&lt;span class="err"&gt;",&lt;/span&gt; &lt;span class="na"&gt;open:&lt;/span&gt; &lt;span class="na"&gt;false&lt;/span&gt; &lt;span class="err"&gt;}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component doesn't throw. It just… doesn't initialize, and depending on where the parser gives up, your expression can &lt;strong&gt;dump into the page as visible text&lt;/strong&gt;. Worse, it often &lt;em&gt;renders fine in your quick manual check&lt;/em&gt; but breaks somewhere else — and it's invisible to a lot of smoke tests because it's an HTML-parse issue, not a JS error.&lt;/p&gt;

&lt;p&gt;The fix isn't "remember to use single quotes." It's: &lt;strong&gt;anything beyond a line or two becomes a registered component.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// static/js/components.js&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alpine:init&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Alpine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;projectBoard&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Save&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// real quotes, real editor, real linting&lt;/span&gt;
    &lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"projectBoard"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; … &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the logic lives in a &lt;code&gt;.js&lt;/code&gt; file your editor and linter understand, the template stays declarative, and the quoting footgun is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: never put a "rich" JS instance on reactive state
&lt;/h2&gt;

&lt;p&gt;This one cost me the most time, so I'll be specific. Alpine makes your state reactive by wrapping it in a &lt;strong&gt;deep Proxy&lt;/strong&gt; (via &lt;code&gt;@vue/reactivity&lt;/code&gt;). That's great for plain data. It's &lt;em&gt;poison&lt;/em&gt; for objects that do their own identity bookkeeping internally — a &lt;strong&gt;TipTap/ProseMirror editor&lt;/strong&gt;, a map instance, a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; controller, a WebSocket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ ProseMirror starts throwing "Applying a mismatched transaction"&lt;/span&gt;
&lt;span class="nx"&gt;Alpine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// now a Proxy&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The editor stores references to its own nodes/state and compares them by identity. Once Alpine has proxied it, &lt;code&gt;this.editor&lt;/code&gt; is &lt;em&gt;not&lt;/em&gt; the object the editor thinks it is, and its internal &lt;code&gt;===&lt;/code&gt; checks fail in baffling ways.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;keep non-plain instances off reactive state entirely.&lt;/strong&gt; A closure is cleanest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ the editor lives in closure scope — never proxied&lt;/span&gt;
&lt;span class="nx"&gt;Alpine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;()&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;chain&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toggleBold&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;()&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;destroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it &lt;em&gt;must&lt;/em&gt; live on &lt;code&gt;this&lt;/code&gt;, mark it so the reactivity engine skips it (&lt;code&gt;obj.__v_skip = true&lt;/code&gt; before assigning, i.e. &lt;code&gt;markRaw&lt;/code&gt;). But closure scope is simpler and I reach for it every time. Rule of thumb: &lt;strong&gt;only plain, serializable data goes on &lt;code&gt;x-data&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: &lt;code&gt;x-data&lt;/code&gt; initializers &lt;em&gt;snapshot&lt;/em&gt; — use &lt;code&gt;init&lt;/code&gt;/&lt;code&gt;x-effect&lt;/code&gt; for async
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;x-data&lt;/code&gt; runs once, eagerly, and takes whatever the expression evaluates to &lt;em&gt;right then&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ❌ `stats` is the Promise fetchStats() returned, not the resolved data --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ stats: fetchStats() }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see &lt;code&gt;[object Promise]&lt;/code&gt; or stale/empty state and chase it for an hour. Load &lt;em&gt;then&lt;/em&gt; assign:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ stats: null, async init() { this.stats = await fetchStats() } }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;x-if=&lt;/span&gt;&lt;span class="s"&gt;"stats"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape bit me with theme switching: reading a value once at init captures it forever. If something needs to &lt;em&gt;react&lt;/em&gt; to a change (a store value, a media query, a fetched result), it belongs in &lt;code&gt;x-effect&lt;/code&gt; or an explicit assignment — not in the initializer expression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: pass server data &lt;em&gt;as data&lt;/em&gt;, not string interpolation
&lt;/h2&gt;

&lt;p&gt;The tempting thing is to jam Django context straight into an Alpine expression: &lt;code&gt;x-data="{ items: {{ items }} }"&lt;/code&gt;. It works until a value contains a quote or a newline, and then you're back in Rule 1's parser hell. Use Django's &lt;code&gt;json_script&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;items&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nf"&gt;json_script&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"board-data"&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ items: JSON.parse(document.getElementById('board-data').textContent) }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server renders JSON safely into a &lt;code&gt;&amp;lt;script type="application/json"&amp;gt;&lt;/code&gt;; Alpine reads it. Clean separation: &lt;strong&gt;Django owns the data, the template just carries it, Alpine consumes it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The small Django-template traps
&lt;/h2&gt;

&lt;p&gt;Two that bit me more than once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{# … #}&lt;/code&gt; is single-line only.&lt;/strong&gt; A comment that wraps across lines renders as &lt;em&gt;literal visible text&lt;/em&gt; — and if it's sitting next to an Alpine attribute, chaos. Use &lt;code&gt;{% comment %} … {% endcomment %}&lt;/code&gt; for anything multi-line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared islands, single-sourced.&lt;/strong&gt; The nav bar, the sidebar, the top bar — build each as &lt;em&gt;one&lt;/em&gt; island partial you include everywhere, not a per-page fork. The day I stopped copy-pasting the nav component was the day the footer stopped mysteriously drifting between pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What "sane" ended up meaning
&lt;/h2&gt;

&lt;p&gt;Nothing exotic — just discipline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Islands, not a SPA.&lt;/strong&gt; Server owns data + page; Alpine owns a widget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register non-trivial components&lt;/strong&gt; in a JS file; inline only for toggles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain data only on &lt;code&gt;x-data&lt;/code&gt;&lt;/strong&gt; — rich instances live in closures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async loads via &lt;code&gt;init&lt;/code&gt;/&lt;code&gt;x-effect&lt;/code&gt;&lt;/strong&gt;, never the initializer expression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server → client via &lt;code&gt;json_script&lt;/code&gt;&lt;/strong&gt;, never string interpolation.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;browser smoke run&lt;/strong&gt; as a release gate, because the worst Alpine bugs are HTML-parse issues that pass unit tests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Alpine scaled to a genuinely large app for me — but only once I stopped treating it like React-lite and started treating it like sprinkles on server-rendered HTML.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these while building a free, full-lifecycle PM platform in the open — if the build-in-public stuff is your thing, the rest of the series is here.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>alpinejs</category>
    </item>
    <item>
      <title>Why I made my project-management SaaS 100% free (and how I plan to survive)</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Wed, 02 Sep 2026 06:04:34 +0000</pubDate>
      <link>https://dev.to/vanillapm/why-i-made-my-project-management-saas-100-free-and-how-i-plan-to-survive-4ge1</link>
      <guid>https://dev.to/vanillapm/why-i-made-my-project-management-saas-100-free-and-how-i-plan-to-survive-4ge1</guid>
      <description>&lt;p&gt;In my last post, I &lt;a href="https://dev.to/vanillapm"&gt;wrote up what it cost to build VanillaPM&lt;/a&gt; — a full PMBOK project-management platform — solo in three months. The reaction was kind. But almost every reply circled the same question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's genuinely free? &lt;em&gt;How are you going to survive?&lt;/em&gt;"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair question. Here's the honest answer — including the part where I admit I don't have it all figured out.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what "free" actually means here
&lt;/h2&gt;

&lt;p&gt;Not a free trial. Not free-for-3-users-then-a-wall. The &lt;strong&gt;entire core is free&lt;/strong&gt; — the full PMBOK 8th-edition lifecycle, charter to closeout, WBS, Gantt, Earned Value, risk registers, a knowledge base, sprints. No seat limits. No paywall on the thing itself. New accounts even open a fully-built sample project so you can use everything on real data before you create anything.&lt;/p&gt;

&lt;p&gt;I did that on purpose, and it wasn't a growth hack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why free — the real reason
&lt;/h2&gt;

&lt;p&gt;The tools that genuinely &lt;em&gt;teach&lt;/em&gt; you project management are locked behind enterprise pricing. The ones you can actually afford don't teach you anything — they're glorified to-do lists. So the people who most need to &lt;em&gt;learn&lt;/em&gt; how to run a project — a student, a freelancer, a first-time founder, a small NGO running on grants — are the exact people priced out of the tools that would teach them.&lt;/p&gt;

&lt;p&gt;I wanted to close that gap. Free removes the friction for precisely the people who need it most. That's the whole point, and a paywall on the core would defeat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger bet
&lt;/h2&gt;

&lt;p&gt;Here's the part that reframes the "how will you survive" question: &lt;strong&gt;VanillaPM isn't "a free PM tool." It's the first module of a teaching business suite.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Project management is the front door because it's where most people first hit the wall. But finance, HR, payroll and org design are already being built on the same spine — the idea being that a small org could eventually run its &lt;em&gt;whole&lt;/em&gt; operation, and learn how to, in one place. Free PM is distribution and trust. The moat is the teaching and the breadth that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  So… how do I plan to survive?
&lt;/h2&gt;

&lt;p&gt;Honestly? I have &lt;em&gt;directions&lt;/em&gt;, not a locked plan — and I'd rather tell you that than pretend I have a tidy spreadsheet.&lt;/p&gt;

&lt;p&gt;What I'm &lt;strong&gt;considering&lt;/strong&gt; (none decided):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed AI, not manual AI.&lt;/strong&gt; Bring-your-own-key AI is free. But hosting the AI — no key, no setup, it just works — is a real cost I could reasonably charge for. Convenience, not capability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth for organisations.&lt;/strong&gt; The ERM finance-to-payroll suite, org-scoped access control at scale, multi-entity separation — the stuff a &lt;em&gt;company&lt;/em&gt; needs, not a student. That's where willingness-to-pay actually lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted / support / SLA tiers&lt;/strong&gt; for teams who want someone on the hook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The teaching layer&lt;/strong&gt; — deeper courses, certifications-prep, guided tracks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I will &lt;strong&gt;never&lt;/strong&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paywall the core PM lifecycle. It stays free.&lt;/li&gt;
&lt;li&gt;Seat-limit the free tier into uselessness.&lt;/li&gt;
&lt;li&gt;Sell your data. Ever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle I keep coming back to: &lt;strong&gt;money should come from depth and convenience, not from holding your project hostage.&lt;/strong&gt; The free core is a promise, not a funnel with a trapdoor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest risk
&lt;/h2&gt;

&lt;p&gt;This might not work. "Free core, charge for depth" is a real model (it's how a lot of good software gets distributed), but plenty of founders have gone broke being generous. I know that.&lt;/p&gt;

&lt;p&gt;I'm building in public partly &lt;em&gt;because&lt;/em&gt; I don't have it fully solved — the feedback is the point. Free-first is a deliberate bet: distribution and trust now, revenue later, from the people and orgs who get enough value that paying feels obvious rather than extracted.&lt;/p&gt;

&lt;p&gt;Ask me again in a year and I'll show you the numbers — the good and the bad. I've been sharing the flops too (my first ad: $3, some clicks, zero signups), and I'm not going to stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;If you've built or used a free-core product: &lt;strong&gt;how would &lt;em&gt;you&lt;/em&gt; monetise this without betraying the "free" promise?&lt;/strong&gt; Genuinely asking — the comments on posts like this have already changed my thinking more than any strategy deck.&lt;/p&gt;

&lt;p&gt;And if you just want to poke at a free, full-lifecycle PM platform (no signup wall — there's a sample project waiting): it's at &lt;strong&gt;&lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;vanillapm.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>saas</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I built a full PMBOK project-management platform solo in 3 months — here's what it cost</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Tue, 01 Sep 2026 06:40:59 +0000</pubDate>
      <link>https://dev.to/vanillapm/i-built-a-full-pmbok-project-management-platform-solo-in-3-months-heres-what-it-cost-16bd</link>
      <guid>https://dev.to/vanillapm/i-built-a-full-pmbok-project-management-platform-solo-in-3-months-heres-what-it-cost-16bd</guid>
      <description>&lt;p&gt;Three months ago, VanillaPM was an empty Django project. Today it runs the entire &lt;strong&gt;PMBOK 8th-edition&lt;/strong&gt; project lifecycle — Initiating through Closing — with a work breakdown structure, critical-path scheduling, Earned Value, risk and assumption registers, a governed knowledge base, sprints, an ERM finance-to-payroll suite, and a bring-your-own-key AI assistant. It's &lt;strong&gt;live&lt;/strong&gt;, it has &lt;strong&gt;over 2,000 automated tests&lt;/strong&gt;, and it's completely &lt;strong&gt;free&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I'm a developer of ~13 years who spent the last few leading delivery, and I wanted to genuinely &lt;em&gt;learn&lt;/em&gt; formal project management — not from slides, but by building the tool I wished existed. This is the honest breakdown: what it cost in money, where the real leverage came from, and a few lessons that surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually cost
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPS — 12 months, prepaid&lt;/td&gt;
&lt;td&gt;$203.88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business email — 48 months&lt;/td&gt;
&lt;td&gt;$228.96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI credits (the build phase)&lt;/td&gt;
&lt;td&gt;$150.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One-time, to a shipped 1.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$582.84&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recurring during the build&lt;/td&gt;
&lt;td&gt;~$46 / month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total to a shipped, tested, deployed 1.0: &lt;strong&gt;under $700.&lt;/strong&gt; No team, no seed round, no office — a $17/month VPS, a mailbox, and some AI credits.&lt;/p&gt;

&lt;p&gt;The line item that matters most is the smallest: that &lt;strong&gt;$150 of AI credits&lt;/strong&gt; is the one that changed what "solo" even means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lever: AI as a force multiplier, not an autocomplete
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't that AI wrote code. It's that it let &lt;em&gt;one person hold the whole system in their head at team throughput.&lt;/em&gt; Scaffolding a new Django app, writing the tests alongside the feature, doing the third tedious refactor of the day, rubber-ducking an architecture decision, and — genuinely — teaching me the PMBOK concepts &lt;em&gt;while I built the features that implement them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is not magic, and it doesn't replace judgment. You still architect. You still decide. You still verify everything, because the model is confidently wrong often enough that trusting it blindly would sink you. But the throughput multiplier is real, and the shape of "what a solo developer can ship" has changed.&lt;/p&gt;

&lt;p&gt;Some concrete numbers from the three months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;58 Django apps&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~84,000 lines of Python&lt;/strong&gt; (excluding migrations)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;320 migrations&lt;/strong&gt; of schema evolution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2,000+ tests&lt;/strong&gt;, behind a CI gate that blocks any merge on a failing test or a coverage drop&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every task got its own branch, ran tests + lint + a &lt;code&gt;makemigrations --check&lt;/code&gt; on every change, and merged with &lt;code&gt;--no-ff&lt;/code&gt;. That discipline is the thing AI &lt;em&gt;didn't&lt;/em&gt; replace — it amplified it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four lessons that surprised me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Tests are what make solo + AI safe.&lt;/strong&gt; Those 2,000 tests aren't a vanity metric — they're the safety net that lets you refactor fearlessly and trust an AI-assisted change you didn't hand-write line by line. Without them, the speed becomes a liability. With them, it compounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Building the PM tool taught me PM better than any course.&lt;/strong&gt; I ran the build of VanillaPM &lt;em&gt;as a VanillaPM project&lt;/em&gt; — a real charter, a WBS, sprints, formal change control on my own scope. Dogfooding turned abstract PMBOK concepts into muscle memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The boring infrastructure decisions compound.&lt;/strong&gt; Postgres end-to-end, object storage for files, a fail-closed search-indexing gate, automated backups, a health-checked deploy. None of it is exciting. All of it is why the thing stays up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Scope is the enemy, and formal change control on your &lt;em&gt;own&lt;/em&gt; project keeps you honest.&lt;/strong&gt; The number of "small" features I talked myself out of via my own change process is the reason 1.0 shipped at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's free
&lt;/h2&gt;

&lt;p&gt;The tools that actually teach you project management are locked behind enterprise pricing; the ones you can afford don't teach you anything. I wanted to close that gap — a place where a student, a freelancer, or a small NGO can run a &lt;em&gt;real&lt;/em&gt; project (and learn how) without a paywall. New accounts even drop straight into a fully-built sample project — a 220-room hotel build with a real WBS, schedule, budget and risk register — so you can explore everything on real data before creating anything of your own.&lt;/p&gt;

&lt;p&gt;There's also a free &lt;strong&gt;Learn PMP&lt;/strong&gt; course and an &lt;strong&gt;ITTO memory game&lt;/strong&gt; for exam prep, in seven languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to look
&lt;/h2&gt;

&lt;p&gt;It's at &lt;strong&gt;&lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;vanillapm.com&lt;/a&gt;&lt;/strong&gt; — free, no trial, no seat limits. I'm building the rest in public and I'll keep sharing the numbers, including the unflattering ones (my first paid ad: $3, a handful of clicks, zero signups — but that's another post).&lt;/p&gt;

&lt;p&gt;If you're a developer curious about the solo-plus-AI workflow, or a PM tired of enterprise pricing, I'd genuinely love your feedback in the comments. What would you have built differently?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>django</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
