Throughout my recent blog transformation, most notably by migrating from zola to Astro, I have started adding features controlled by env flags: comments, newsletter, other projects sidebar, translation notice, digital garden. The comments feature came first, then going dual language with a separate Slovak domain meant the translation notice flag needed to behave differently per locale too. I wanted Playwright tests to cover all of them properly — meaning features both on and off, in both English and Slovak. Four combinations total.
I wanted to have a test coverage as always, but getting there turned out to be a much longer journey than I expected.
The naive approach #
My first instinct was to keep two builds (EN and SK) and pass feature flags at runtime when starting the Wrangler dev server:
wrangler pages dev dist-en --binding FEATURE_COMMENTS=true --port 4321
wrangler pages dev dist-en --binding FEATURE_COMMENTS= --port 4322
The --binding flag looked promising. It did nothing. The feature flags
were still coming from .dev.vars, which takes silent precedence over
--binding with no warning whatsoever. I only figured this out by
eliminating everything else.
Next attempt was --env-file:
wrangler pages dev dist-en --env-file tests/env/features-on.env
This one does something, just not what you want. The --env-file flag
injects values into Node.js process.env, the process running Wrangler
itself. It does not touch the Cloudflare Worker runtime environment, which
is what locals.runtime.env reads from inside an Astro endpoint or server
island. So the Worker sees nothing, the flags stay off.
Baking features into the build #
At this point it was clear the only reliable option was to bake the flags
into the build itself. Vite has a define config for exactly this — it
does a literal string substitution at build time:
vite: {
define: {
"import.meta.env.FEATURE_COMMENTS": JSON.stringify(process.env.FEATURE_COMMENTS === "true"),
}
}
This seemed to work until I checked the actual built worker file and found:
const showComments = false
Baked as false even in the features-on build. The reason: Astro’s
Cloudflare adapter SSR transform rewrites every import.meta.env.X access
to process.env.X in the worker bundle. This runs after Vite’s define
pass, quietly undoing it. There is no error. The value just silently
becomes whatever process.env holds at runtime in the Worker — which is
nothing.
The fix is to use custom identifiers that Astro does not recognise as
import.meta.env accesses and therefore does not rewrite:
vite: {
define: {
__FEATURE_COMMENTS__: JSON.stringify(process.env.FEATURE_COMMENTS === "true"),
}
}
And in the component:
const showComments = __FEATURE_COMMENTS__
After this the correct true or false gets baked into each build.
Four builds, shared D1 #
With four separate builds (EN off, EN on, SK off, SK on) the test setup
runs four Wrangler instances in parallel. The D1 database needed to be
accessible from all of them. Running each with its own state directory
meant that wrangler d1 migrations apply blog-db --local only applied to
whichever instance it happened to find first. The other three would either
skip or fail silently with:
✅ No migrations to apply!
Even when the table did not actually exist yet.
The fix is --persist-to .wrangler/state on every instance:
wrangler pages dev dist-test/en-off --port 4321 --persist-to .wrangler/state
wrangler pages dev dist-test/en-on --port 4322 --persist-to .wrangler/state
wrangler pages dev dist-test/sk-off --port 4323 --persist-to .wrangler/state
wrangler pages dev dist-test/sk-on --port 4324 --persist-to .wrangler/state
All four now share the same SQLite file under .wrangler/state. Migrations
apply once and all instances see the result.
A few more surprises #
Server islands. The comments section loads via Astro’s server:defer —
asynchronously in the browser after the page loads. Playwright checks the
DOM before the island fetches, finds nothing, test fails. Adding
await page.waitForLoadState("networkidle") after navigation fixes this.
Content Security Policy. The public/_headers file had
script-src 'self' without 'unsafe-inline'. Astro’s server island
mechanism injects content using inline scripts, which the browser silently
blocks. The island stays stuck on “Loading comments…” forever. Adding
'unsafe-inline' to script-src fixed it. Worth noting this applies in
production too since the same _headers file is deployed to Cloudflare
Pages.
CSRF protection. Astro 5 enables CSRF protection by default in SSR
mode. It checks the Origin header on POST requests. Playwright’s API
request context does not send one, so every API test got:
Cross-site POST form submissions are forbidden
as a 403 response. Fixed by adding extraHTTPHeaders: { Origin: baseURL }
per project in playwright.config.ts.
The result #
Four Playwright projects, each pointing at its own pre-built Wrangler instance. Features baked in at build time, not passed at runtime. One shared D1 state directory. Took longer than it should have but the setup is clean now.