Playwright e2e flake: shared seed user's last_project_id bypasses /select-project#
Problem#
Playwright e2e tests in packages/frontend/e2e/ intermittently failed locally on select-project.spec.ts:7 -- "multi-project login routes through selector and POSTs /projects/select". The test timed out waiting for /select-project and landed on / instead. GitHub CI never saw the flake because CI already pinned workers: 1; only local multi-worker runs were affected. The root issue was shared-user state leakage across e2e specs, not random timing.
Symptoms#
select-project.spec.ts:7times out waiting for navigation to/select-project, landing on/instead.- Failure is non-deterministic locally — passes on rerun, never reproduces in CI.
- Stress-running the suite (4× consecutive raw e2e runs) initially appears green, masking the structural bug.
- Other specs that exercise
test@hashhive.local(notablysmoke.spec.ts:7and the "sidebar sign-out" test in the same file) silently mutateusers.last_project_idas a side effect.
What Didn't Work#
- "Re-run to confirm flake." Passed on retry, which made it look like a Playwright timing issue rather than database state leakage. The flake mechanism is real but state-driven: order-of-execution determines whether the user already has
last_project_idset when the test signs in. test.describe.serialalone (first pass). Wrappingselect-project.spec.tsintest.describe.serial(...)fixed the in-file ordering between "sidebar sign-out" and "multi-project login routes through selector". Local stress-testing showed 4× green runs. But Copilot's reviewer pointed out thatdescribe.serialonly orders tests within one file.smoke.spec.tsline 23-24 also signs in astest@hashhive.localand clicks "Test Project" on/select-projectif it lands there -- which writesusers.last_project_id. Under multi-worker,smoke.spec.tsandselect-project.spec.tscan race across files, anddescribe.serialdoes nothing for that.
Solution#
Two-layer fix: pin the suite to a single worker in playwright.config.ts, and keep the in-file describe.serial annotation on the shared-user block.
packages/frontend/playwright.config.ts -- pin workers to 1 everywhere, not just CI:
// `workers: 1` enforced everywhere, not just CI. The e2e suite shares
// a single seeded user (`test@hashhive.local`) AND shared backend
// state -- any spec that picks a project on `/select-project` writes
// `users.last_project_id`, and BetterAuth's session.create.before hook
// rehydrates `session.projectId` from that column on the next sign-in.
// With multiple workers, cross-file races produce intermittent "land
// on / instead of /select-project" timeouts that are invisible in
// CI's single-worker run. Until each spec has an isolated seeded
// user OR `last_project_id` is reset between tests, single-worker is
// the only durable answer. `fullyParallel: true` stays because it's
// the right default once state isolation is in place; it's a no-op
// while `workers: 1` is in effect.
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: 1,
packages/frontend/e2e/select-project.spec.ts -- keep in-file serialization:
// `serial` is the in-file half of the state-isolation answer: both
// tests sign in as `test@hashhive.local`, and the sign-out test picks
// "Test Project" -- which writes `users.last_project_id`. The
// `playwright.config.ts` `workers: 1` setting handles the cross-file
// half (smoke.spec.ts also mutates `last_project_id` via the same
// selector flow). Both halves are load-bearing until each spec has
// its own seeded user.
test.describe.serial('Multi-project select flow (issue #160)', () => {
// ...existing tests...
})
GOTCHAS.md -- new entry under Frontend Testing: "Playwright e2e suite runs single-worker until each spec has its own seeded user."
Why This Works#
The flake is a state leak between specs, not a timing race:
test@hashhive.localis a shared seed user across the e2e suite (packages/frontend/e2e/setup/seed-data.ts).- Any spec that clicks "Test Project" on
/select-projectPOSTs to/projects/select, which writesusers.last_project_id. - BetterAuth's
session.create.beforehook inpackages/backend/src/lib/auth.tsreadsusers.last_project_idon every sign-in and rehydratessession.projectId. - If
select-project.spec.ts's multi-project login test signs in after any spec has setlast_project_id, the backend skips the selector and routes the user to/.
workers: 1 eliminates cross-file parallel scheduling, so no other spec can mutate the row while select-project.spec.ts is mid-flight. describe.serial eliminates in-file parallel scheduling for the two tests that share the seed user, which is the same root cause at a smaller scope. The two layers are complementary: serial documents the in-file dependency at the call site for future readers; workers: 1 defends against future specs that add new uses of the shared user.
This is also why demo-capture.spec.ts is excluded from the default suite — same root cause, called out in playwright.config.ts's existing comment block.
Prevention#
just ci-checkbefore push, not justjust check. CI runs the full e2e suite, which is where state-leak bugs surface.just check(format + lint + type-check + build) does not catch this class.- Don't relax
workers: 1without first wiring per-spec isolation. Same rule for droppingdescribe.serialon any block that touchestest@hashhive.local. - Follow-up: per-spec seeded users. Each spec creates its own user via the seed harness (e.g.,
select-project-user@hashhive.localdistinct fromsmoke-user@hashhive.local) so no two specs shareusers.last_project_id. This is the durable answer that unlocksfullyParallel: true+workers: undefinedfor real. - Follow-up:
beforeEachreset. Cheaper interim: a PlaywrightbeforeEach(or a backend test-only endpoint) that resetsusers.last_project_id = NULLfor the shared seed user before each test in any spec that signs in astest@hashhive.local. Less durable than per-spec users but unblocks parallel runs sooner. - Audit rule. Any new e2e spec that signs in as
test@hashhive.localmust either (a) live inside adescribe.serialblock whose first test does NOT pick a project, OR (b) resetlast_project_idinbeforeEach. TheGOTCHAS.mdFrontend Testing entry is the call-site reminder.
Related#
GOTCHAS.md— Frontend Testing section, entry "Playwright e2e suite runs single-worker until each spec has its own seeded user". This solution doc is the deeper structured form; GOTCHAS is the quick-reference.docs/plans/2026-05-26-001-feat-betterauth-project-selection-rbac-plan.md— issue #159, introduced thesession.create.beforehook +users.last_project_idrehydration that is the upstream root cause.docs/plans/2026-05-27-002-feat-login-project-selection-ui-plan.md— issue #160 / PR #178, wiredPOST /api/v1/dashboard/projects/selectand the selector UI that the e2e specs hit.packages/frontend/playwright.config.ts—testIgnorecomment block explaining the same root cause fordemo-capture.spec.ts.