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.