DEV Community

Cover image for Playwright isn't just for tests — I use it to screenshot my own product
Ahsan Ahmad for VanillaPM

Posted on AI-assisted

Playwright isn't just for tests — I use it to screenshot my own product

I needed screenshots of my app. Lots of them — for the docs, for a pitch deck, for the lessons in the learning centre. Twenty-one feature areas, each needing several shots, all in dark mode, all at retina resolution, all showing the same sample project so the set looks coherent.

I did it by hand once. It took most of a day, and about a week later I changed the nav and roughly a third of them were wrong.

The second time I wrote a Playwright script. It now takes about four minutes and I can re-run it after any UI change.

This is the part of Playwright nobody mentions: it's a programmable browser. Testing is the popular use, not the only one.

The shape of it

One file, tour.py. It logs in once, then walks a named area of the app and shoots as it goes:

python tour.py --area cost-evm --theme dark
python tour.py --area schedule --headless
Enter fullscreen mode Exit fullscreen mode

Each area is a plain function:

def tour_cost_evm(page, area="cost-evm"):
    open_sample_project(page)
    page.get_by_text("Budget & Expenses", exact=True).first.click()
    page.wait_for_selector("text=Approved budget", timeout=15000)
    pause(page, 2)

    shoot(page, area, "E1-budget-summary")

    page.get_by_text("Earned Value", exact=False).first.scroll_into_view_if_needed()
    pause(page, 2)
    shoot(page, area, "E3-earned-value")
Enter fullscreen mode Exit fullscreen mode

and they're registered in a dict so the CLI can dispatch to any of them:

TOURS = {"schedule": tour_schedule, "cost-evm": tour_cost_evm,
         "risk-governance": tour_risk_governance, "ai": tour_ai, ...}
Enter fullscreen mode Exit fullscreen mode

That's the whole architecture. Twenty-one of those functions, a handful of helpers, an argparse CLI.

Four things that made it actually work

1. Set browser state before any page script runs

This was the fix that turned a frustrating afternoon into a working tool. My app reads the theme from localStorage on boot, and shows a cookie notice until dismissed. Setting those after the page loads meant a visible flash of light mode, and a cookie banner in the first screenshot of every run.

add_init_script runs before any page's own JavaScript:

context.add_init_script(
    "try{"
    "localStorage.setItem('vpm-theme','dark');"
    "localStorage.setItem('cookieNoticeDismissed','1');"
    "localStorage.setItem('vpmdemo','1');"
    "}catch(e){}"
)
Enter fullscreen mode Exit fullscreen mode

Theme is correct from the first paint. No banner, ever. If you're screenshotting an app with any client-side state — theme, onboarding tooltips, "what's new" modals — this is the hook you want.

2. device_scale_factor=2

context = browser.new_context(
    viewport={"width": 1600, "height": 1000},
    device_scale_factor=2,
)
Enter fullscreen mode Exit fullscreen mode

One line, and every still comes out at 3200×2000. Retina screenshots without a retina display. Fixed viewport also means the whole set is dimensionally consistent, which matters a lot once they're laid out next to each other.

3. You get video for free

context = browser.new_context(
    record_video_dir="captures/video",
    record_video_size={"width": 1600, "height": 1000},
)
Enter fullscreen mode Exit fullscreen mode

Playwright records the session. So the same run that produces the stills also produces b-roll of the app being used — which turned out to be more useful than the screenshots for anything with motion.

The catch: the file is only flushed when the context closes, so close it properly:

finally:
    context.close()   # flushes the video file
    browser.close()
Enter fullscreen mode Exit fullscreen mode

It writes .webm. Convert to MP4 if your editor is fussy.

And because the recording is real-time, I added deliberate pauses so the footage doesn't look like a seizure:

def pause(page, secs=1.5):
    """A deliberate on-screen pause — makes the b-roll feel like a calm demo."""
    page.wait_for_timeout(int(secs * 1000))
Enter fullscreen mode Exit fullscreen mode

Pauses are pointless for tests. They're the entire difference between usable and unusable b-roll.

4. Wait for rendered content, not for the network

My Gantt chart is drawn by JavaScript into an empty div. networkidle fires long before any bars exist, so I was reliably screenshotting an empty rectangle.

page.wait_for_selector("#gantt-chart svg, #gantt-chart .bar", timeout=20000)
Enter fullscreen mode Exit fullscreen mode

Wait for the thing you're trying to photograph, not for a proxy. Obvious in hindsight; cost me twenty minutes.

The bug worth stealing

I had a view-toggle with buttons labelled List / Timeline / Variance. The obvious selector:

page.click("button:has-text('List')")
Enter fullscreen mode Exit fullscreen mode

It kept clicking the wrong thing. Somewhere else in the DOM was a hidden tab button labelled "Closure checklist" — which contains "List", as a substring, case-insensitively.

:has-text() does substring matching. The fix:

page.get_by_role("button", name="List", exact=True).click()
Enter fullscreen mode Exit fullscreen mode

There's a comment in my source warning the next person, which is to say warning me:

# view toggles: matched in code via get_by_role(name=…, exact=True) — a CSS
# :has-text('List') also matched the hidden "Closure checklist" tab button.
Enter fullscreen mode Exit fullscreen mode

Role + exact name beats text-substring selectors, in tests and here equally.

Was it worth it?

The script is 720 lines. The manual process was about a day, so on pure time I broke roughly even on the first run.

The payoff isn't the first run. It's that screenshots stopped being a thing I dread. Change the nav, re-run, done. Need every shot in light mode for a printed doc? --theme light. Need a specific area for a lesson? One command.

They stopped being artefacts I maintain and became output I regenerate — which is the same shift as going from hand-written fixtures to factories, applied to marketing assets.

Honest limits

  • Selectors still break. Rename a nav item and the tour fails. Mine is littered with # CONFIRM comments on selectors I wasn't certain about. It's a maintenance burden, just a much smaller one than re-shooting by hand.
  • It needs stable sample data. The screenshots are only coherent because every tour opens the same seeded project. Without that you get twenty-one unrelated-looking apps.
  • Headed mode is better for b-roll. --headless is faster, but I run headed when I want the video, because some animations behave differently.

If you maintain product screenshots by hand, this is a weekend's work that pays back every time your UI changes. And you almost certainly have Playwright installed already — it's just pointed at the wrong job.


Next and last in the series: **Part 4* turns Playwright into a debugging instrument — hunting a CSS bug that had no visible element.*

I'm building *VanillaPM** — a free, full-lifecycle PM platform — in Django, in the open. The rest of this Playwright for Django series is here if build-in-public engineering is your thing.*

Top comments (0)

⌂ 换网址