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
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")
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, ...}
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){}"
)
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,
)
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},
)
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()
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))
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)
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')")
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()
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.
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
# CONFIRMcomments 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.
--headlessis 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)