Smoke test environment gotchas
Non-obvious behaviors in the Playwright smoke suite that have caused repeated failures. Read before modifying auth.*.setup.ts, journey.spec.ts, or any spec that navigates to /dashboard.
See docs/development/testing.md for the canonical testing entry point — unit, E2E, and Smoke Testing (Playwright) sections, including quick-reference commands and suite inventory. See docs/development/playwright-smoke-gate.md for gate severity by environment and PR-merge-gate status.
Gotcha 1 — Firebase *.web.app preview URLs route to smoke_env=production
Applies to: .github/workflows/playwright-smoke.yml, auth.production.setup.ts, auth.staging.setup.ts
Firebase preview channel URLs (enkidu-app--pr-*.web.app) are not registered in Clerk staging's allowed-origins list. Because of this, Clerk.loaded never resolves on those domains — the staging setup path hangs waiting for Clerk and times out.
The fix (OBJ-644): playwright-smoke.yml detects *.web.app in the frontend URL and sets smoke_env=production instead of smoke_env=staging:
# .github/workflows/playwright-smoke.yml — Resolve smoke environment step
elif [[ "${{ steps.urls.outputs.frontend_url }}" == *".web.app" ]]; then
# Firebase preview channel URLs are not in Clerk staging's allowed origins.
# Route to production smoke_env so auth.production.setup.ts uses
# FALLBACK_AUTH_SECRET (no Clerk browser dependency). See OBJ-644.
echo "smoke_env=production" >> "$GITHUB_OUTPUT"Consequence: when a PR's smoke run targets a preview channel, auth.production.setup.ts runs — not auth.staging.setup.ts. The two files are not equivalent environments despite their names:
| File | Auth method | What it writes |
|---|---|---|
auth.staging.setup.ts | Real Clerk sign-in (email_code) | Session cookies + objectuve_onboarding_completed_at + objectuve_first_sign_in + setup_dismissed_<id> for each goal |
auth.production.setup.ts | Fallback token (no Clerk browser) | objectuve_fallback_token + objectuve_first_sign_in=false + creates a baseline goal if none exist + setup_dismissed_<id> for each goal |
The trap: if you add a new localStorage dismissal flag or data-hygiene step to auth.staging.setup.ts, you must add the same step to auth.production.setup.ts. Otherwise preview channel runs land without that state and downstream specs break — but staging and production smoke runs pass, making the failure look intermittent.
Files to keep in sync:
ionic_frontend/tests/smoke-playwright/setup/auth.staging.setup.tsionic_frontend/tests/smoke-playwright/setup/auth.production.setup.ts
localStorage keys that MUST be seeded in BOTH files (a missing key only breaks the other environment, so the gap reads as an intermittent failure):
objectuve_suppress_celebrations— AchievementWatcher overlay gate (Gotcha 8)objectuve_e2e— Dashboard StreakCelebration / welcome-back / BadgeModal gate (Gotcha 8)objectuve_first_sign_in,objectuve_onboarding_completed_at— onboarding routing (Gotcha 2)setup_dismissed_<id>per goal, andseen_achievements_{publicId}(defense-in-depth)
A smoke-auth-parity.spec.ts unit test guards a subset of this parity — extend it when you add a new shared key.
Gotcha 2 — Demo user onboarding state persists across smoke runs
Applies to: ionic_frontend/tests/smoke-playwright/specs/auth-onboarding/journey.spec.ts — test 02-onboarding
journey.spec.ts:02-onboarding assumes the demo user is not yet onboarded. It sets objectuve_first_sign_in=true in localStorage, navigates to /welcome/start, walks the wizard slides, and clicks the done-slide CTA — which fires the completeOnboardingAndCreateGoal mutation:
// journey.spec.ts — 02-onboarding
await page.addInitScript((token: string) => {
localStorage.setItem('objectuve_fallback_token', token)
localStorage.setItem('objectuve_first_sign_in', 'true') // ← client-side only
}, journeyState.fallbackToken!)
await page.goto('/welcome/start')
// ... wizard steps ...
await page.locator('[data-testid="done-complete"]').click()
// fires completeOnboardingAndCreateGoal → sets onboarding_completed_at in the DB
await page.waitForURL(/\/goals\/[^/?]+/, { timeout: 20_000 })The mutation writes backend state. Once completeOnboardingAndCreateGoal succeeds, onboarding_completed_at is set for the demo user in the database. On the next smoke run:
objectuve_first_sign_in=trueis set in localStorage (client hint only)- The backend still returns
onboarding_completed_atas non-null whensyncUserruns - The onboarding wizard does not surface, or behaves differently for an already-onboarded user
- The
waitForURL(/\/goals\/[^/?]+/)assertion times out (30s+, 41× waiting)
Setting objectuve_first_sign_in=true in localStorage is not enough. It is a client-side routing hint. It does not reset API state.
storeOnboardingState cannot fix this either. That mutation writes or updates the data["onboarding"] blob — it accepts completed_via: "complete" | "skip" and progress. It cannot null completed_at or delete the blob. Only resetOnboarding (see below) clears the backend state.
Fix shipped in PR #924 (v3.9.270). 02-onboarding now calls resetOnboarding via apiResetOnboarding before navigating to /welcome/start, and clears objectuve_onboarding_completed_at from localStorage via addInitScript. The spec is now idempotent across re-runs.
// journey.spec.ts — 02-onboarding (current state after PR #924)
await graphqlRequest(
`mutation { resetOnboarding { success errors } }`,
{},
journeyState.fallbackToken!,
)
await page.addInitScript((token: string) => {
localStorage.setItem('objectuve_fallback_token', token)
localStorage.setItem('objectuve_first_sign_in', 'true')
localStorage.removeItem('objectuve_onboarding_completed_at')
}, journeyState.fallbackToken!)
await page.goto('/welcome/start')How resetOnboarding works
Mutations::ResetOnboarding (rails_api/app/graphql/mutations/reset_onboarding.rb) deletes the onboarding key from user_details.data entirely. Once deleted, useOnboardingGate.ts sees onboardingCompletedAt === null and shouldOnboard becomes eligible (assuming the other conditions are met). The mutation is idempotent — calling it on a user with no onboarding blob is a no-op.
Self-reset relaxation (A1). Before PR #924, the mutation required caller.admin? for all calls. This blocked the demo user (not an admin) from resetting its own state. The fix relaxes the gate for the self case:
- No
userPublicIdarg: any authenticated user may reset their own onboarding. No admin role required. The mutation targetscontext[:current_user]. - With
userPublicIdarg: admin required. Cross-user targeting is unchanged.
This is safe: the demo user resets its own data, the mutation is idempotent, and the wizard re-completes on the same run.
Why fallback-auth sessions can still reach /welcome/*
The useOnboardingGate composable short-circuits for fallback-auth sessions: isBypassAuth is true when Session.getFallbackToken() is non-null, so shouldOnboard is always false and the router's forward-redirect to /welcome/start never fires. This means 02-onboarding cannot rely on the automatic redirect — it navigates directly to /welcome/start instead.
The direct navigation is safe because of the D-05 router exemption in router/index.ts:424:
export function evaluateOnboardingGate(to: RouteLocationNormalized): RouteLocationRaw | null {
if (to.path.startsWith('/welcome')) return null // D-05
...
}Any navigation to a /welcome/* path bypasses the onboarding gate check entirely, so it resolves without a redirect loop regardless of session type.
Spec 10 and parallel-worker safety
10-existing-user-not-re-onboarded.spec.ts (SAFE-03) asserts that an already-onboarded user lands on /dashboard and never routes through /welcome. Its precondition is that the demo user has onboarding_status.completedAt set.
With 02-onboarding now calling resetOnboarding before each run, completedAt may be null when spec 10 checks. Rather than fail fast, spec 10 now self-establishes the precondition: if completedAt is null (e.g. a parallel run with spec 02 reset the state), it calls completeOnboardingAndCreateGoal to set it, re-queries to confirm, then proceeds with the assertion. This makes spec 10 safe to run in parallel with spec 02 across Playwright workers.
Gotcha 3 — smoke_env=production: demo account goal state is not guaranteed
Applies to: ionic_frontend/tests/smoke-playwright/setup/auth.production.setup.ts, any spec that depends on GoalsSection, GettingStartedCard, or the "Create new goal" button
The demo account (demo@objectuve.com) is a long-lived shared account. It can be left with 0 active goals — for example after a smoke run clears all goals, or after manual testing. When the account has no goals, hasNoGoals is true and the dashboard renders Branch A: no GoalsSection, no GettingStartedCard, and no "Create new goal" button. Specs that navigate to /dashboard and then assert goal-related UI silently time out.
The guard (must not be removed):
auth.production.setup.ts queries the demo account's active goals after authenticating. If goals.length === 0, it fires completeOnboardingAndCreateGoal to seed a baseline goal before writing storageState:
// auth.production.setup.ts
if (goalIds.length === 0) {
await request.post(getGraphqlEndpoint(), {
headers: { 'Content-Type': 'application/json', 'SessionToken': fallbackToken },
data: {
query: `mutation { completeOnboardingAndCreateGoal(goalName: "Smoke baseline goal") { user { publicId } errors } }`,
},
failOnStatusCode: false,
})
// re-fetches goalIds after mutation ...
}Without this guard, any spec that asserts GoalsSection, GettingStartedCard, or the Branch C/D "Create new goal" button will fail on a clean demo account.
Both setup files now have the guard (OBJ-744): auth.staging.setup.ts gained an equivalent self-healing check in the same if (dismissToken) block — it queries goals, recreates the baseline goal via completeOnboardingAndCreateGoal if count is zero, then throws a hard assertion error ('Smoke user has 0 goals after setup — dashboard Branch C/D will not render. Aborting.') if still zero. The guard in auth.production.setup.ts predates this; both files are now structurally equivalent.
Rule: never remove or skip the goal-seeding guard in either auth.production.setup.ts or auth.staging.setup.ts. If you refactor either file, keep the goal-check and mutation block intact and keep both files in sync (see Gotcha 1 for the sync rule).
Diagnosed: OBJ-704 (CI re-run investigation). Fixed in commit 7553033b.
Gotcha 4 — Missing coach persona redirects away from /enneagram-assessment
Applies to: ionic_frontend/tests/smoke-playwright/specs/coach-enneagram/enneagram-assessment.spec.ts, any new spec that navigates to /enneagram-assessment
The demo account starts with no coach persona configured (no enneagram assessment started, coachingPreferences is null). Navigating to /enneagram-assessment in this state triggers an internal redirect to /coach. A spec that calls page.goto('/enneagram-assessment') and then immediately asserts assessment-page content will time out waiting for elements that are never rendered — it is sitting on /coach, not the assessment page.
Correct pattern — check URL before asserting:
await page.goto('/enneagram-assessment')
// Wait for the navigation to settle, then check where it landed.
await page.waitForURL(
url => url.pathname === '/enneagram-assessment' || !url.pathname.includes('enneagram-assessment'),
{ timeout: 15_000 },
).catch(() => {})
if (!page.url().includes('/enneagram-assessment')) {
test.skip(true, 'Demo account redirected away from /enneagram-assessment — coach persona not set up')
}
// Only assert assessment content if we are still on the right page.
await expect(page.getByRole('heading', { name: 'Enneagram Assessment' })).toBeVisible({ timeout: 15_000 })This pattern is implemented in enneagram-assessment.spec.ts — use it as the reference for any new spec that navigates to assessment pages.
Why test.skip instead of a hard fail: the redirect is expected when the demo account is in its baseline state (no persona). Failing CI for a missing coach persona would mask unrelated regressions. Skip the spec cleanly and let the comment explain why.
Diagnosed: OBJ-704 (CI re-run investigation). Fixed in commit 7553033b.
Gotcha 5 — Post-networkidle watchers in Dashboard.vue can open overlays that block subsequent clicks
Applies to: Any spec that navigates to /dashboard and then clicks UI — particularly ionic_frontend/tests/smoke-playwright/specs/goals-habits/dashboard-goal-create.spec.ts and any future spec that interacts with dashboard elements after page load.
Dashboard.vue has reactive watchers that fire once the USER_QUERY GraphQL response resolves. Because the query response arrives after networkidle, these watchers run after waitForLoadState('networkidle') returns in the spec. Watchers that open modals via <Teleport to="body"> insert a full-screen overlay that intercepts all pointer events — causing subsequent .click() calls to time out with no obvious error.
The concrete case (OBJ-756): watch(actions, ...) at Dashboard.vue:1073 checks for an unacknowledged streak_comeback action. If one is present, it opens BadgeModal.vue at z-index: 99999. The production smoke storageState does not include the dismissed-actions localStorage key, so the modal opens on every run where the demo account has a pending comeback action — blocking clicks on the "Create new goal" button (~32 s timeout).
The guard pattern (mirrors the existing WelcomeBack guard already in the file):
// After waitForLoadState('networkidle'), before clicking dashboard UI
const closeBadge = page.locator('[aria-label="Close badge details"]')
try {
await closeBadge.waitFor({ state: 'visible', timeout: 5_000 })
await closeBadge.click()
await closeBadge.waitFor({ state: 'hidden', timeout: 5_000 })
} catch {
// BadgeModal not present — proceed
}Rule: Apply this pattern to every modal-opening watch() in Dashboard.vue. At time of writing, two watchers can open a modal:
| Watcher | Modal | aria-label to dismiss |
|---|---|---|
useWelcomeBack composable | WelcomeBackInterstitial | "Close welcome back offer" |
watch(actions, …) at line 1073 | BadgeModal | "Close badge details" |
If a new watcher is added that opens a Teleport modal, add a corresponding try/catch guard to every spec that navigates to /dashboard before that modal has a chance to resolve.
Why try/catch instead of if (await locator.isVisible()): isVisible() is point-in-time and races against the async watcher. waitFor({ state: 'visible', timeout: N }) correctly handles the case where the modal hasn't opened yet but will within N ms. The catch branch handles the case where it never opens.
Diagnosed: OBJ-755 (Dave's root-cause investigation) / fixed in PR #955 (OBJ-756).
Gotcha 6 — Ionic ion-router-outlet keeps both leaving and entering pages in the DOM during transitions
Applies to: ionic_frontend/tests/smoke-playwright/specs/auth-onboarding/journey.spec.ts and any spec that clicks shared chrome (headers, footers, nav tabs) while navigating between routes backed by a shared layout component.
During animated route transitions, Ionic's ion-router-outlet appends the entering ion-page after the leaving one. Neither page has ion-page-hidden (display:none) while the animation runs. This means Playwright strict-mode locators that match elements present in SlideWizardLayout (or any shared layout component) — such as footer CTA buttons — will resolve to 2 elements during the animation window and throw a strict-mode violation.
Fix pattern: Use .last() to target the entering page (always appended after the leaving page, so DOM order is deterministic). Use .first() only if you have explicit DOM-order reasoning that proves it. Do not use .nth(0) / .nth(1) without confirming the animation window timing in your specific test.
// ❌ strict-mode violation during ion-router-outlet transition
await page.getByRole('button', { name: 'Continue' }).click();
// ✅ targets the entering (active) page
await page.getByRole('button', { name: 'Continue' }).last().click();Scope: This applies to any spec that clicks shared chrome (headers, footers, nav tabs) while navigating between routes backed by a shared layout component.
Diagnosed: OBJ-781 (Dave's root-cause investigation across 5 failing PRs). Fixed in PR #962.
Gotcha 7 — WAAPI animation patch in smoke.ts overrides Element.prototype.animate globally
Applies to: Every spec that imports from ionic_frontend/tests/smoke-playwright/fixtures/smoke.ts (all smoke specs via the shared fixture)
Ionic 8 drives page-enter transitions via element.animate() — the Web Animations API — which ignores CSS animation-duration overrides. In CI headless mode the WAAPI Animation.finished promise never resolves, leaving ion-page in a perpetual transitioning state; every child element's bounding box changes continuously and Playwright's stability check never passes.
The patch:
smoke.ts uses base.extend to wrap every page fixture with an addInitScript that replaces Element.prototype.animate globally, forcing every animation to complete in 1ms:
// ionic_frontend/tests/smoke-playwright/fixtures/smoke.ts
const baseWithAnimationPatch = base.extend({
page: async ({ page }, use) => {
await page.addInitScript(() => {
const orig = Element.prototype.animate.bind(Element.prototype)
Element.prototype.animate = function (keyframes, options) {
const opts =
typeof options === 'number' || options == null
? { duration: 1 }
: { ...options, duration: 1 }
return orig(keyframes, opts)
}
})
await use(page)
},
})Caution: This patch applies to the entire page context for every spec. If you write a test that intentionally asserts on animation timing (e.g. "element should not be visible until Xms have elapsed"), you must either opt out of the shared fixture or mock animate separately in that spec. Any future ion-router-outlet transition behavior change that relies on accurate WAAPI timing will also be masked by this patch.
Diagnosed: OBJ-706 (smoke hardening PR #922).
Gotcha 8 — Celebration overlays are suppressed at the source in smoke (objectuve_suppress_celebrations + objectuve_e2e), NOT guarded in-spec
Applies to: both auth.*.setup.ts files, AchievementWatcher.vue, Dashboard.vue, and any spec that touches /dashboard or the onboarding wizard.
Two distinct watchers can throw a fixed inset-0 z-[9999] overlay that, under reducedMotion:reduce, never auto-dismisses and blocks all pointer events:
AchievementWatcher.vuepollsUSER_QUERYevery 10 s and opensLegendaryCelebration.vue(and achievement toasts) for any unacknowledgedUserAction.Dashboard.vueopensStreakCelebration, the welcome-back card, andBadgeModalfrom post-networkidlewatchers (see Gotcha 5).
Why setup-time seeding alone was not enough. Early attempts seeded seen_achievements_{publicId} (and/or server-acknowledged actions) at login. But the journey spec generates new legendary actions mid-run: its goal create / complete mutations enqueue CheckBadgesJob (update_goal.rb → check_badges_job.rb), which writes fresh UserAction rows after setup ran. The 10 s poll then surfaces a brand-new overlay at an arbitrary mid-assertion point. No login-time seed can cover an action that does not exist yet at login.
The fix — two source-level localStorage gates, seeded in both setup files:
| Flag | Read by | Suppresses |
|---|---|---|
objectuve_suppress_celebrations | AchievementWatcher.vue (celebrationsSuppressed()) | LegendaryCelebration + achievement toasts surfaced by the watcher — even for actions generated mid-run |
objectuve_e2e | Dashboard.vue (isE2ESession()) | StreakCelebration (v-if="… && !isE2ESession()"), the welcome-back card, and the BadgeModal watch(actions) |
When set, the watchers mark the action seen but never render the overlay, so there is nothing to dismiss. Both flags mirror the objectuve_fallback_token test-auth precedent and are never set outside smoke setup.
seen_achievements_{publicId} is now defense-in-depth only — auth.production.setup.ts still seeds it (keeps the demo account's localStorage tidy and covers login-time actions), but it is no longer load-bearing for overlay suppression. Do not rely on it alone.
Consequences for spec authors:
- Do NOT add in-spec overlay-dismiss guards. The old
legendaryModal/dismissStreakCelebration/ epic-toast-wait scaffolding injourney.spec.tswas removed in OBJ-836 — it was dead once these gates landed. Re-introducing awaitForTimeout/ poll-dismiss loop just re-adds 10 s-poll flake. - Overlay behavior is covered by unit tests, not smoke:
ionic_frontend/tests/unit/LegendaryCelebration.spec.tsexercises open / acknowledge-on-open / "Keep going" dismiss / "View badge" navigate deterministically.
Diagnosed: OBJ-706 (initial seen_achievements seeding, PR #922) → OBJ-835 (objectuve_suppress_celebrations gate) → OBJ-801 / OBJ-836 (removed the dead in-spec guards; unit-tested the overlay). The objectuve_e2e Dashboard gate landed alongside (split from the soft-grace work).
Gotcha 9 — Coach-persona slide: .last() is unreliable; click every stacked instance
Applies to: ionic_frontend/tests/smoke-playwright/specs/auth-onboarding/journey.spec.ts — slide 6 (/welcome/coach-persona)
ion-router-outlet keeps all visited ion-page instances alive in the DOM. By the time journey.spec.ts reaches slide 6 (/welcome/coach-persona), multiple stacked CoachPersonaSlide instances exist simultaneously. Each instance owns its own reactive showDetail state — flipping showDetail on one instance does not affect the others.
Why .last() fails: Stale ion-page instances may occupy higher DOM positions (appended later) than the visually-active one. A .last() locator targeting [data-testid="persona-card-*"] or [name=/^Choose /] may resolve to a stale hidden instance rather than the active slide, and waitFor({ state: 'visible' }) on a card in a hidden-but-not-detached stale page will hang the entire test budget.
Fix pattern (implemented in journey.spec.ts:144-160):
// 1. Wait for at least one persona card to attach
await page.locator('[data-testid="persona-card-captain"]').first().waitFor({ state: 'attached', timeout: 15_000 })
// 2. Click the card on EVERY stacked instance to flip showDetail on all of them
const personaCards = await page.locator('[data-testid="persona-card-captain"]').all()
for (const card of personaCards) {
await card.dispatchEvent('click').catch(() => {})
}
// 3. Wait for the Choose button to attach (not visible — stale instances may hold it in a hidden state)
await page.getByRole('button', { name: /^Choose / }).first().waitFor({ state: 'attached', timeout: 15_000 })
// 4. Click EVERY stacked Choose button — only the active wizard's listener fires slideNext()
const chooseButtons = await page.getByRole('button', { name: /^Choose / }).all()
for (const btn of chooseButtons) {
await btn.dispatchEvent('click').catch(() => {})
}
// 5. Confirm navigation — proves the active instance's slideNext() fired
await page.waitForURL(/\/welcome\/coach-tone/, { timeout: 15_000 })Why waitFor('attached') instead of waitFor('visible') for the Choose button: A stale ion-page may hold a Choose button in a bounded but non-visible state (opacity:0, transform) that never satisfies Playwright's visibility heuristic. Using state: 'attached' avoids hanging the test budget on a stale instance. dispatchEvent('click') — not .click() — is used to bypass Playwright's viewport/visibility checks, since only the active wizard's chooseAndAdvance handler will actually advance the URL.
Diagnosed: OBJ-706 (smoke hardening PR #922).
Gotcha 10 — Flag-gated routes: assert a .or() dual/triple-outcome sentinel, never waitForURL or a populated-list aria-label
A route whose beforeEnter guard redirects based on an async check (a PostHog feature-flag lookup that fails closed, an auth probe, etc.) cannot be asserted with waitForURL. page.goto('/allies/pending') lands on the requested path first, so any waitForURL that accepts that path resolves before the flag-OFF guard's async redirect to /dashboard fires — the assertion then runs down the flag-ON branch for a page that is about to navigate away, producing the observed ~30s timeout across all browsers.
A second, related trap: even when the flag is ON, the populated-list region may not render. /allies/pending asserts [aria-label="Pending ally requests"], which is v-else-if="requests.length > 0". The demo account often has zero pending requests, so the empty state ("You're all caught up.") renders instead and the populated-list assertion times out on a clean run.
Do not paper over either failure with a fixed settle window (await page.waitForTimeout(3000)): it is slow, flaky on cold preview environments, and still races a slow flag fetch.
Correct pattern — chain every valid terminal state into one .or() expression; no waitForURL, no if/else URL branching. Only one branch is ever in the DOM (they live on different pages), so .first() just guards strict mode:
await page.goto('/allies/pending')
// .or() resolves when ANY valid terminal state appears:
// flag ON + populated → list region
// flag ON + no requests → empty-state heading
// flag OFF (default) → dashboard sentinel
await page.locator('[aria-label="Pending ally requests"]')
.or(page.locator('h3:has-text("You\'re all caught up.")'))
.or(page.locator('[aria-label="Create new goal"]'))
.first()
.waitFor({ state: 'visible', timeout: 45_000 })For a simple two-outcome route, the same shape with .toBeAttached() (not .toBeVisible() — a transitioning ion-page held at opacity:0 would hang the budget; see Gotcha 6):
const discoverySearch = page.locator('[aria-label="Search for allies by name or username"]') // flag ON
const dashboardSentinel = page.locator('[aria-label="Create new goal"]') // flag OFF → /dashboard
await expect(discoverySearch.or(dashboardSentinel).first()).toBeAttached({ timeout: 45_000 })This dual/triple-outcome .or() pattern is the canonical way to write a smoke spec for any flag-gated route — the flag state on the deployed environment is not knowable from the test, and the populated state is not guaranteed even when the flag is on.
Canonical examples: ionic_frontend/tests/smoke-playwright/specs/platform/ally-discovery.spec.ts (two-outcome) and pending-ally-requests.spec.ts (three-outcome, incl. empty state).
Diagnosed: OBJ-847 (Phase 108b CI investigation — empty-state miss, fixed in 8a68e6781) and OBJ-851 / OBJ-854 (Phase 112b icons-audit DOCS-DEBT, Roy on PR #1001 — async-guard race). Structural .or() fix landed in PR #974 (519d23bc0); cherry-picked to master in PR #1009 (OBJ-845).
Gotcha 11 — Downstream serial failures are masked when an upstream test fails; fixing the upstream test surfaces them as fake "new" regressions
Smoke specs that run with test.describe.serial (or a serial project) stop the chain at the first failure — every downstream test in that block is skipped, not run. The practical consequence for triage: when you fix the failing upstream test, the next CI run executes the previously-skipped downstream tests for the first time, and any that were already broken now fail visibly — in the same PR that fixed the upstream test. They look like regressions the PR introduced. They are not.
The trap: A reviewer (or routing agent) sees "this PR fixed one test but broke two others" and attributes the two failures to the PR's changes. In reality the PR only un-masked pre-existing failures by clearing the serial blockage above them.
Triage before attributing a downstream failure to a PR:
- Does the same spec fail on master independently of the PR? (Check recent master smoke runs.)
- Does the PR touch the frontend at all? A Rails- or agent-runner-only PR cannot introduce a Vue rendering/animation flake — if the failing spec is a UI flake, it is pre-existing.
- Check the spec's CI history — was it ever actually running (vs skipped) before the upstream fix?
Reference: PR #996 — fixing 02-onboarding (a Vue Router push fix) unblocked the serial chain and surfaced two pre-existing failures (03b-habit-create goals heading + 03-goal-create mobile-android). Dave's analysis confirmed both were pre-existing and unrelated to #996. (Source: OBJ-865.)
Gotcha 12 — Two data/field traps that masquerade as independent spec bugs (USER_QUERY cascade; data-state assertions)
12a — Staging-vs-preview field cascade. When a feature branch adds a new GraphQL field to a query that is consumed app-wide — USER_QUERY is the canonical example, with ~14 consumers — every page that loads that query will field-error against the staging schema until the PR merges (staging has the old schema; the preview build sends the new selection). Symptom: many unrelated specs time out at once with no obvious common cause. Diagnosis: grep the query name under src/ — if it is loaded across many views, the failures are almost certainly one staging cascade, not N independent test bugs. Fix: ship the PR — the cascade clears on the next staging deploy. Do not add test.skip to the affected specs.
12b — Data-state-dependent assertions. A smoke spec should assert unconditional page structure (the h1, nav, layout chrome) — never content that depends on the smoke account's mutable data state. Asserting [aria-label="Pending ally requests"] (which only renders when requests.length > 0) fails on any run where the demo account happens to have zero pending requests. Use the .or() empty-state sentinel from Gotcha 10 instead.
Source: OBJ-812, PR #979 (Orion respec #2 + Tess R2). (Source: OBJ-866.)
Gotcha 13 — Smoke-rebase-drift: branches cut before master's cold-start hardening carry stale assertion timeouts after rebase
Root cause: Firebase Preview cold-starts need 20–45 s to first paint. Master hardens smoke specs against this with three things: an addInitScript cold-start shim, 30–45 s assertion timeouts (not the 5 s default), and toBeAttached() over toBeVisible() for transitioning ion-pages (Gotcha 6). A feature branch cut before those hardening commits keeps its old short timeouts. After it rebases onto hardened master, the merge is clean but the spec assertions are stale — they still use sub-30 s timeouts — so they ghost-fail on pages that have nothing to do with the feature, and burn multiple fix rounds before anyone realizes the branch simply never picked up master's timeout hardening.
Fix recipe:
git diff master -- ionic_frontend/tests/smoke-playwright/on the rebased branch.- For any spec whose assertion timeouts are
< 30_000or that usestoBeVisible()onv-else/post-API content, restore master's version of that assertion (chromium project needs the cold-start init; mobile projects need the longer timeouts). - Treat a ghost failure on a page unrelated to the feature as a drift signal first, a real regression second.
Reference: PRs #974 and #979 both hit this exact pattern — branch cut → master hardened cold-start timeouts → rebase → stale assertions → ghost failures unrelated to the feature. (Source: OBJ-852.)
Gotcha 14 — mobile-android: a multi-field form's submit button can sit below the viewport — .click() fails the actionability check
On the mobile-android (Pixel 7) device profile, after a spec fills several inputs on a form page (e.g. /goals/create — goal name + description), the submit control can end up below the Playwright-visible viewport — especially if a soft keyboard is open. A plain await locator.click() runs Playwright's actionability check (scroll-into-view + viewport visibility + stability) and can time out even though the button exists in the DOM. Symptom: the test fails on the first attempt and passes on retry (the form state or scroll position differs), looking like a flake.
This is distinct from the cold-start timeout problem (Gotcha 7 / Gotcha 13) — widening the timeout doesn't help, because the click never lands.
Fix: scroll the button into view defensively, then dispatchEvent('click') to bypass the viewport/actionability gate — the same pattern already used for the onboarding-slide buttons and goal-event-submit:
// ❌ can time out on mobile-android when the button is below the fold
await page.locator('[data-testid="goal-create-submit"]').click()
// ✅ scroll + dispatchEvent bypasses the actionability/viewport check
await page.locator('[data-testid="goal-create-submit"]').scrollIntoViewIfNeeded().catch(() => {})
await page.locator('[data-testid="goal-create-submit"]').dispatchEvent('click')Rule: any spec that fills a multi-field form and then submits should use scrollIntoViewIfNeeded() + dispatchEvent('click') on the submit button on mobile-android targets. (The companion cold-start timeout fix — { timeout: 30_000 } on the /goals/create heading — already lives in journey.spec.ts.)
Why .click({ force: true }) isn't the fix, even though it looks like one: force: true only skips Playwright's actionability checks (visible, stable, receives pointer events) — it does not bypass the underlying requirement that a coordinate-based click land within the currently-computed viewport. If the target element is genuinely below the fold, .click({ force: true }) still fails with Element is outside of the viewport, deterministically, on every run — not flakily, since there's no timing race to retry into. This is a different failure mode from the plain-.click() actionability timeout this gotcha otherwise describes: a timeout means Playwright gave up waiting for the checks to pass, whereas the viewport error means the click coordinates were rejected outright. scrollIntoViewIfNeeded() + dispatchEvent('click') is the correct fix for both, because dispatchEvent fires the DOM event directly on the element instead of computing click coordinates at all. Reproduced on mobile-safari for completion-sheet-primary in 05-goal-complete (OBJ-1189): the existing spec already used .click({ force: true }), and it failed identically before and after the surrounding component change, because force: true was never addressing a viewport-position problem in the first place.
Diagnosed: OBJ-864 (Tess, #1018); submit-fix re-extracted to master here (the timeout half was already on master). Nuance on force: true vs. the viewport-coordinate check added under OBJ-1189 (Codi/Roy, #1316).
Gotcha 15 — Dependabot PRs show cypress-e2e: skipped — this is intentional, not a misconfiguration
Dependabot PRs skip the cypress-e2e tier (if: github.actor != 'dependabot[bot]' in .github/workflows/ci.yml) because Dependabot runs with read-only secrets and cannot read VITE_CLERK_PUBLISHABLE_KEY; @clerk/vue throws throwMissingPublishableKeyError and crashes at bootstrap without it. Dependabot bumps are dependency-only — they don't change app logic — so skipping the E2E tier on those PRs is safe. If you see cypress-e2e: skipped on a Dependabot PR, do not add continue-on-error, remove the condition, or attempt to provision the secret for Dependabot — the skip is the correct outcome.
Source: OBJ-895, PR #1062 (Roy review comment).
Gotcha 16 — Ephemeral preview channel smoke is advisory-only — do not attempt to re-promote to hard-blocking
Applies to: .github/workflows/playwright-smoke.yml, anyone tempted to remove the is_ephemeral_preview advisory carve-out
OBJ-1024 ran 3 fix-rounds across PRs #1213 and #1218 attempting to make the Playwright smoke gate uniformly hard-blocking (including on Firebase ephemeral PR preview channels). All 3 rounds failed with a new cold-start surface each time:
| Round | Root cause | Fix attempted | Outcome |
|---|---|---|---|
| R1 | WelcomeView JS chunk — CDN cold miss | Removed advisory carve-out (no warmup change) | ❌ smoke fail |
| R2 | Onboarding slide rendering | Added /welcome/start networkidle to smoke.warmup.ts | ❌ smoke fail (page crash at slide 5) |
| R3 | resetOnboarding GraphQL resolver (~25s cold) | Added resetOnboarding pre-warm + test.setTimeout(120_000) | ❌ smoke fail |
By R3 the warmup consumed 215s of the 240s project budget with no convergence path. The cold-start surface on Firebase ephemeral preview channels is empirically unbounded — each PR channel spins up a fresh Cloud Run instance with no min-instances pre-warming, and there is always another cold resolver or CDN miss waiting after each fix.
Josh's decision (2026-07-03): advisory-on-ephemeral is the durable design. Staging (min-instances > 0) remains the real production hard gate.
What this means in practice:
- The
is_ephemeral_previewadvisory carve-out inplaywright-smoke.ymlis intentional and permanent unless Cloud Run min-instances change on preview channels. - Smoke failures on
enkidu-app--pr-*.web.appchannels produce::warning(not::error) and do not block PR merge. - The staging hard gate catches regressions before production on every merge.
If you're tempted to remove the carve-out: read OBJ-1024 first. Each fix round took a full 38-minute CI run to disprove. The carve-out is cheaper than another round.
Source: OBJ-1024 (Tess R1-R3, Orion respec, Josh Q2 decision). See docs/development/playwright-smoke-gate.md for the full gate-by-environment reference.
Gotcha 17 — Four recurring locator/data-shape traps found fixing the 2026-06-19 smoke wave
Applies to: clerk-sign-up.spec.ts, journey.spec.ts (goal/habit detail assertions), communities-overhaul.spec.ts, meet-coach.spec.ts — and any new spec that touches sign-up, goal/habit-name assertions, category chips, or desktop/mobile variant rendering.
Four specs went red identically on every staging run from 2026-06-19 through 2026-07-03 (OBJ-1088). Each root cause is a distinct pattern likely to recur in future specs touching the same surfaces.
17a — Clerk's hosted <SignUp> form now requires username + password, not just email. clerk-sign-up.spec.ts filled only the email field and clicked Continue; the click landed but Clerk's native required validation silently blocked the submit (no visible error, no console error — just no navigation), so the OTP field the test waited on next never rendered. Fix: fill input[name="username"] and input[name="password"] when present (.count() > 0 guard), before filling email:
const usernameField = page.locator('input[name="username"]')
if (await usernameField.count() > 0) {
await usernameField.fill(`smoke_clerk_test_${Date.now()}`)
}
const passwordField = page.locator('input[name="password"]')
if (await passwordField.count() > 0) {
await passwordField.fill('Smoke-Test-Password-1!')
}The .count() > 0 guard means this also degrades safely if Clerk's instance config reverts to email-only. Separately, the sign-up form's social-auth row renders a "Sign in with Google Continue" button, so a case-insensitive /continue/i name match resolves to 2 buttons (strict-mode violation) — use an exact match: page.getByRole('button', { name: 'Continue', exact: true }).
Also note: this spec must run in a signed-out context. The project-level storageState: AUTH_FILE pre-authenticates every browser project, so inheriting it means /sign-up hits Clerk's force-redirect-url="/dashboard" while already signed in — it redirects to /dashboard before the email field ever renders. Override with test.use({ storageState: { cookies: [], origins: [] } }) at the top of the describe block.
17b — GoalCreate.vue defaults to the Coach-drafted "hero" phase (OBJ-1082); a bare page.goto('/goals/create') skips the manual form. Any spec asserting [data-testid="goal-name-input"] or the description field after a bare navigation times out — those inputs don't exist until phase === 'form', and the "Create Goal" header text renders in both phases, so a header-visibility check doesn't catch a hero-phase landing. Fix: use the existing, unit-tested deep-link that skips the hero and lands directly on the manual form: page.goto('/goals/create?source=wizard').
17c — The roadmap-capstone widget re-renders the goal/habit name elsewhere on the goal detail page — bare getByText(goalName) is a strict-mode violation. journey.spec.ts's goal/habit-detail steps waited on page.getByText(journeyState.goalName!, { exact: false }) to confirm the page loaded. The same name also renders inside a RoadmapCapstone.vue widget on the page (as a <span>, not a heading), so the bare text locator resolves to 2 matches. Fix: scope to the page's actual <h1> heading, which GoalHero.vue renders and RoadmapCapstone.vue does not: page.getByRole('heading', { name: goalName, exact: false }). Any future spec asserting a goal/habit name via bare getByText on a goal detail page will hit this same collision.
17d — CSS-hidden elements are still in the DOM; .or() over a desktop/mobile variant pair can match both simultaneously. meet-coach.spec.ts used heading.or(mobileProgressBar) to accept either the desktop or mobile rendering of the wizard's first step. MeetCoach.vue's 'editing' wizard mode keeps both the desktop heading (hidden lg:block) and the mobile progress bar (lg:hidden) in the DOM at all times — only Tailwind's breakpoint classes toggle visibility, so .or() resolves to 2 DOM matches and throws a strict-mode violation before visibility is even considered. Fix: poll each locator's own isVisible() independently instead of combining them with .or():
const desktopHeading = page.getByRole('heading', { name: /Meet your Coach/ })
const mobileWizardBar = page.getByLabel('Setup progress')
await expect
.poll(async () => (await desktopHeading.isVisible()) || (await mobileWizardBar.isVisible()), { timeout: MUTATION_LONG })
.toBe(true)This pattern applies to any spec asserting "either the desktop or mobile variant of X is visible" where both variants are always DOM-present.
A fifth, related fix in the same round — communities-overhaul.spec.ts's category chips are not a fixed enum. The test asserted a hardcoded category-name regex (/Personal Development|Health|Career|Wealth|Wellbeing/i), but Communities.vue's dynamicCategories computed derives chips from whatever categories are actually present on real (non-fixture) community data — there is no fixed set. Fix: assert the filter group structurally (an "All" chip always renders; a real category chip only when chips.count() > 1) rather than by name — same convention as the file's other cold-state branches.
Diagnosed and fixed: OBJ-1088 (Tess, R1–R3, PR #1248). Confirmed against source (MeetCoach.vue, RoadmapCapstone.vue, GoalHero.vue, Communities.vue, CommunityCategoryChip.vue, playwright-core's Locator.isVisible()/strict-mode internals) by Roy on review.
Gotcha 18 — .not.toContainText() on a conditionally-rendered element fails on every passing run, not just failing ones
journey.spec.ts's 03b-habit-create: checks in on a habit from the dashboard step added a regression guard (OBJ-1140, PR #1264) to fail loudly if Dashboard.vue renders its error state instead of "Today's practice":
// WRONG — fails on every successful run, not just error runs
await expect(page.getByRole('alert')).not.toContainText("Let's get your dashboard back")Dashboard.vue's error block only puts role="alert" in the DOM inside its v-else-if="hasError" branch — on every successful render (the common case), zero elements match getByRole('alert') at all. Playwright's toContainText() (and its .not. variant) requires the locator to actually resolve to an element to evaluate text content; when it resolves to nothing, the assertion retries until timeout and then fails with Error: element(s) not found — this is NOT the same as "vacuously passes when absent" the way toBeVisible()/toBeHidden() behave. The net effect: this guard failed identically on every single run after it was merged, including CI's own properly-gated playwright-smoke job against a clean deploy, which briefly looked like a "new regression" in an unrelated PR (Phase 114, OBJ-1082) that happened to merge one commit later.
Fix: use .not.toBeVisible() instead of .not.toContainText() when the locator only exists in the DOM under an error/conditional branch — it correctly treats "no matching element" as passing, and only fails if the element renders and is visible:
// RIGHT — passes when the element never renders; fails only if it actually appears
await expect(page.getByRole('alert')).not.toBeVisible({ timeout: WARM_PAGE_HYDRATION })Since role="alert" in Dashboard.vue is unique to the error branch, checking visibility alone (without also checking the text) is sufficient — there's no other content that could produce a false negative.
General rule: before asserting .not.toContainText() / .not.toHaveText() on a locator, confirm the target element is unconditionally present in the DOM (just empty or non-matching text). If it's a v-if/v-else-if branch that may not render at all, use .not.toBeVisible() or .not.toBeAttached() instead.
Diagnosed and fixed: OBJ-1082 dashboard-regression false alarm (Tess, single round, PR TBD — see journey.spec.ts fix commit). Confirmed by re-running the CI-gated playwright-smoke job against current master (run showed the identical failure across chromium/mobile-safari/mobile-android, ruling out Dave's demo-reset-race hypothesis) and tracing the assertion to Dashboard.vue:124-128's v-else-if="hasError" block.
Gotcha 19 — demo:reset Cloud Run Job ran fire-and-forget, racing the smoke run's dashboard queries
Applies to: .github/workflows/staging.yml, .github/workflows/production.yml, journey.spec.ts step 03b-habit-create and its downstream chained steps (shows habit tracking section on goal detail, filters habits on the goals list page).
Across three Staging Deployment runs on 2026-07-04/07-05, 03b-habit-create failed on chromium, mobile-safari, and mobile-android with expect(locator).toBeVisible() failed — Locator: getByText('Today's practice'). The dashboard was rendering its hasError state (Dashboard.vue's v-else-if="hasError" branch, gated by USER_QUERY/GOALS_QUERY both erroring — see hasError computed at Dashboard.vue:769), not a rendering bug in the "Today's practice" section itself.
Root cause (best-supported hypothesis; the actual server-side exception was never directly observed — no Cloud Run log access from the diagnosing environment): the Reset Demo Account step in both deploy workflows invoked gcloud run jobs execute enkidu-demo-reset-staging without --wait — fire-and-forget. The job runs bundle exec rake demo:reset against the shared staging demo account. The playwright-smoke job is gated by needs: [deploy-backend, deploy-frontend, deploy-litellm] at the job level, but that only waits for the deploy-backend job to finish — if demo:reset itself doesn't block within that job, the smoke run can start while the reset is still mutating (or has just wiped) the demo account's data, and the dashboard's queries race a half-reset account.
Fix (PR #1264): add --wait to both demo:reset job executions so the deploy job itself blocks until the reset completes:
-gcloud run jobs execute enkidu-demo-reset-staging --region ${{ env.REGION }}
+gcloud run jobs execute enkidu-demo-reset-staging --region ${{ env.REGION }} --waitSame change in production.yml for enkidu-demo-reset. This matches the existing --wait pattern already used on db:migrate and seed:goal_templates in the same workflows — demo:reset was the one job execute call that had been missed.
Verification: Tess re-ran playwright-smoke against a post-merge master commit and confirmed the race itself is gone — the demo:reset --wait step now completes cleanly ~2 minutes before the smoke job starts. The regression-guard assertion this PR also added to journey.spec.ts (expect(page.getByRole('alert')).not.toContainText("Let's get your dashboard back"), intended to fail loudly on a real dashboard error instead of a generic 30 s timeout) was itself broken — role="alert" is only in the DOM inside the hasError branch, so on every passing run the locator resolves to zero elements, and .not.toContainText() still requires a match to evaluate, causing element(s) not found on 100% of runs, race or no race. That is a distinct trap — see Gotcha 18 (added on the fix, fix/journey-hasError-guard-matcher, tracked under OBJ-1082) for the .not.toContainText()-on-a-conditional-element matcher pitfall and its .not.toBeVisible() fix.
Status as of this writing: the --wait race fix is merged and confirmed. The watch window (3–5 Staging Deployment runs actually exercising the corrected guard) has not yet run — it was blocked on the Gotcha 18 guard-matcher fix merging first, since the broken guard fails every run before the original flaky assertion is ever reached.
Diagnosed and fixed: OBJ-1140 (Dave's hypothesis chain H1–H6, Orion's task package, Tess's fix, Riley CI-green, Roy review, Vicki ship — PR #1264, squash SHA 20344f52f).
Gotcha 20 — Photo deletion has no standalone mutation; the disposable smoke account must be re-found with User.with_deleted, not a plain find_by
Applies to: ionic_frontend/tests/smoke-playwright/specs/platform/settings-profile-photo-upload.spec.ts, rails_api/lib/tasks/smoke_accounts.rake (ensure_photo_upload_account) — any future smoke spec that needs to exercise a GCS storage.objectAdmin delete grant.
20a — There is no deletePhoto/removePhoto mutation. UserIdentity::UpdateUserPhoto (rails_api/app/interactions/user_identity/update_user_photo.rb) only supports setting a photo (file or image_url input) — there is no delete path on the photo itself. The only way to trigger photo deletion at the app level is to destroy the owning User: has_one :photo, class_name: 'UserPhoto', dependent: :destroy (rails_api/app/models/user.rb:45). UserIdentity::UpdateUser's delete_user: true branch (rails_api/app/interactions/user_identity/update_user.rb:19-21) calls user.destroy, which cascades to the photo association, and ActiveStorage purges the attached blob from GCS asynchronously via Sidekiq. This is why the upload+delete smoke spec (OBJ-1391) needs a disposable account rather than the shared demo/preview accounts — the only delete lever destroys the whole user.
20b — User is acts_as_paranoid with plain (non-partial) unique indexes on email/username, so a soft-deleted disposable account will collide on recreation unless you look for it with with_deleted. user.destroy is a soft delete (acts_as_paranoid, rails_api/app/models/user.rb:31) — it sets deleted_at and the row stays. users.email and users.username both have plain unique indexes (index_users_on_email, index_users_on_username in db/schema.rb), not scoped to deleted_at IS NULL. A naive User.find_by(email: …) on the next run's setup won't see the soft-deleted row (paranoia's default scope excludes it), falls through to User.create!, and hits a uniqueness violation on the same email — the account can never be re-provisioned without manual intervention.
Fix pattern (rails_api/lib/tasks/smoke_accounts.rake, ensure_photo_upload_account): look the account up with User.with_deleted, and if it's soft-deleted, really_destroy! it before recreating:
user = User.with_deleted.find_by(email: email)
if user&.deleted?
user.really_destroy!
user = nil
end
user ||= User.create!(first_name: 'Photo', last_name: 'Smoke', ...)This differs from ensure_preview_account's sibling teardown_preview_account task, which hard-deletes (really_destroy!) immediately rather than leaving a soft-deleted row to reconcile later — either approach is correct, but pick one per account lifecycle: if a spec itself soft-deletes the account mid-run (as this one does via deleteUser: true), the next setup must reconcile with with_deleted, not assume a clean table.
Rule: any new smoke-account rake task for an account a spec itself deletes (soft or hard) must query with User.with_deleted before deciding whether to create.
Diagnosed: OBJ-1391 (Tess, building the staging photo-upload/delete smoke spec, PR #1500). Confirmed by Roy on review by tracing update_user_photo.rb, update_user.rb:19-21, user.rb:45, and the plain unique indexes in schema.rb.
Gotcha 21 — waitForResponse's postData() is always null for multipart/form-data uploads; matching on it can never resolve, regardless of server behavior
Applies to: any Playwright spec that calls page.waitForResponse(...) around a GraphQL mutation whose input includes a file upload (Upload scalar via apollo-upload-client) — canonical example: ionic_frontend/tests/smoke-playwright/specs/platform/settings-profile-photo-upload.spec.ts.
UpdateUserPhotoMutation takes $file: Upload. The frontend's Apollo Client wires uploads through apollo-upload-client's createUploadLink (ionic_frontend/src/apollo-client.ts:5,32), which sends any request carrying a File/Blob variable as multipart/form-data instead of a plain JSON body. Playwright's Request.postData() returns null for any request with a binary/multipart part — there is no text body to return.
The trap: the original spec matched the upload's response via a request-body predicate:
// ❌ can never match — postData() is null for any multipart request, upload succeeded or not
await page.waitForResponse(resp =>
resp.request().postData()?.includes('UpdateUserPhotoMutation') ?? false
)null?.includes(...) evaluates to undefined, and undefined ?? false (or any truthiness check on it) is always falsy. The predicate is structurally unable to match, independent of whether the mutation actually succeeded server-side — so waitForResponse times out identically whether the upload worked perfectly or the storage grant was completely broken. This is the same failure family as Gotcha 18 (an assertion whose outcome is independent of the real state), but on the request side instead of a DOM locator.
Fix — match on the parsed response body instead, never on postData() for an upload:
// ✅ resolves correctly whether the mutation succeeds or fails server-side
const updateResponsePromise = page.waitForResponse(async (resp) => {
if (!resp.url().includes('/graphql') || resp.request().method() !== 'POST') return false
try {
const body = await resp.json()
return body?.data?.updateUserPhoto !== undefined
} catch {
return false
}
}, { timeout: MUTATION_LONG })This works in both the pass and mutation-test-failure cases because UserIdentity::UpdateUserPhoto#call (rails_api/app/interactions/user_identity/update_user_photo.rb:14-24) wraps its body in rescue StandardError — even a fully broken storage.objectAdmin grant returns a normal { userPhoto: nil, errors: [...] } GraphQL response, not a transport-level error, so data.updateUserPhoto is always present on the parsed body to match against. The actual pass/fail signal then comes from asserting the mutation's errors array and imageUrl field on updateBody, not from whether the response predicate matched.
Rule: never predicate a waitForResponse match on resp.request().postData() for a request known to carry a file upload — match on the awaited, parsed response body (await resp.json()) instead. This applies to any current or future GraphQL mutation using the Upload scalar, not just photos.
Diagnosed: OBJ-1391 (Orion, tracing a Playwright HAR/network log after the post-merge staging playwright-smoke run initially reported the upload as failed — the mutation had actually succeeded; only the spec's own wait predicate was broken). Fix implemented by Tess, independently re-derived and confirmed correct by Roy on review of PR #1502 (update_user_photo.rb:14-24's rescue StandardError guarantee).
Gotcha 22 — Chronic ~15% staging smoke failure rate is mobile-safari-only, well after warmup, and lands on three distinct causes bundled under one symptom
Applies to: the entire mobile-safari Playwright project; helpers/timeouts.ts; specs/auth-onboarding/clerk-sign-in.spec.ts; specs/platform/pwa/01-pwa-foundation.spec.ts.
Vicki flagged a chronic ~15% staging smoke failure rate independent of any specific PR (6 of ~39 runs failing in a 24h window). Every one of those failures — and every other staging smoke failure sampled across 2026-07-19/20 (runs 29694051331, 29703538494, 29748895507 attempts 1–2) — was exclusively on the mobile-safari project. chromium and mobile-android never failed in any sampled run. Three distinct root causes were bundled under this one symptom:
- Clerk
email_codesign-in race, recurring despite a prior fix.clerk-sign-in.spec.tschainsclerk.signOut()→clerk.signIn(). OBJ-1190 already added awaitForFunction(() => Clerk.session == null)guard between them, but the race kept recurring (Error: Clerk: Failed to sign in: email_code is not enabled.) —Clerk.session === nullclient-side does not guarantee Clerk's server has finished tearing down the prior session/strategy state. Fix: retryclerk.signIn()itself (up to 3 attempts, short backoff) specifically on this error string, rather than widening the pre-wait further. - WebKit
apiRequestContextchunked-encoding parse bug.01-pwa-foundation.spec.ts'srequest.get('/statics/icons/pwa-512.png')intermittently threwapiRequestContext.get: Parse Error: Invalid character in chunk sizeon mobile-safari only — the server response was a normal 200 (visible in the failing run's own request log), so this is WebKit's request-context HTTP client choking on the response framing, not a product bug. Fix: retry once onParse Errorfor everyrequest.get()call in the file. - Systemic mobile-safari-under-CI slowness, not cold-start. The remaining ~20 failures per run spanned unrelated specs (dashboard, communities, monetization, coach/enneagram, settings, goals) with no shared code path — the only thing they had in common was landing on
mobile-safariand missing an explicit assertion timeout (15_000/30_000/45_000, or theWARM_API_RESPONSE/WARM_PAGE_HYDRATION/MUTATION_LONGconstants) by single-digit seconds, well after thewarmupproject had already eliminated cold-start (confirmed: failures occurred 15–40+ minutes into runs, not at the start).playwright.config.tsrunschromium/mobile-safari/mobile-androidconcurrently against the shared CI runner's CPU budget oncewarmupcompletes; WebKit's rendering cost under that contention pushes its warm-state P95 past the old ceilings while chromium/mobile-android stay comfortably inside them. Fix: bumpedWARM_API_RESPONSE15s→20s,WARM_PAGE_HYDRATION30s→40s,MUTATION_LONG45s→60s inhelpers/timeouts.ts(a single edit covers every spec already importing the constants), and migrated the ~10 specs still using bare15_000/45_000literals (enneagram-assessment,todays-actions,08-achievements,goals-list-filter,dashboard-goal-create,checkout-success,feedback-submission,supporter-overhaul,ally-discovery,check-in-flow,settings-profile-edit) to import the shared constants instead, so future tuning is a one-line change again.
Not a fix (deliberately out of scope this round): re-architecting Playwright's project concurrency (e.g. serializing mobile-safari against the other two projects) would remove the contention at its root but roughly triples total smoke wall-clock time on an already 40+ minute job — too large a blast-radius change to make without a dedicated respec. If the widened budgets above don't fully close the gap, that is the next lever, not a fourth round of timeout bumps.
Diagnosed: OBJ-1566 (Tess, reading the actual failed-run logs for 29694051331, 29703538494, and both attempts of 29748895507 rather than reproducing locally — the suite needs live staging secrets not available outside CI). Fix: PR TBD.
Gotcha 23 — A shared hard-timeout budget constant silently absorbs unrelated tuning: six no-op .first().waitFor({state:'detached'}) waits exhausted 02-onboarding's entire budget
Applies to: ionic_frontend/tests/smoke-playwright/specs/auth-onboarding/journey.spec.ts (02-onboarding), ionic_frontend/tests/smoke-playwright/helpers/timeouts.ts.
Staging Playwright smoke started failing on 02-onboarding across chromium/mobile-safari/mobile-android, apparently hanging on the coach-rhythm slide (slide 9). It wasn't — that was a misattribution. The trace showed slide 9 rendered correctly and its own visibility check resolved in 4ms once the test actually reached it.
Root cause: the spec had six .first().waitFor({ state: 'detached', timeout: WARM_API_RESPONSE }).catch(() => {}) calls (after slides 1→2, 2→3, 3→4, 4→5, 6→7, 8→9), meant to wait for the previous slide's DOM to detach before interacting with the next. They could never succeed: Ionic's ion-router-outlet intentionally keeps every visited /welcome/:slideId page mounted in its navigation stack rather than removing it (the same page-stacking behavior documented in Gotcha 6 and Gotcha 9). Every one of the six waits was structurally guaranteed to burn its full WARM_API_RESPONSE timeout before the .catch() swallowed it.
That alone wasn't previously fatal — it just ate margin. OBJ-1566's WARM_API_RESPONSE bump (15,000ms → 20,000ms, Gotcha 22) — made for an unrelated mobile-safari CPU-contention fix elsewhere in the suite — pushed the six waits' combined guaranteed dead time from ~90s to ~120s, landing exactly on ONBOARDING_WIZARD_SPEC's 120s ceiling and turning a latent margin-eroder into a hard failure. Because the test's step sequence is fixed, whichever detached-wait first pushed cumulative time over budget determined where the "hang" appeared — the sixth one, immediately before slide 9's own check, which is why coach-rhythm got blamed for a bug two slides upstream of it.
The general trap: an intentionally-optional, best-effort wait (.catch(() => {})-swallowed, expected to sometimes time out) sharing a constant with hard-timeout assertions elsewhere lets a legitimate, unrelated bump to that constant silently re-erode a different test's margin to zero. The failure then surfaces as a mystery on whatever step happens to run out the clock — not on the step that actually regressed.
Fix (PR #1668): removed all six no-op detached-waits — every subsequent slide's own interactions already target the active instance via .last() / aria-hidden-aware locators (Gotcha 6, Gotcha 9), so no synchronization value was lost. A CI guardrail step ("Guard against shared-budget timeouts on detached-waits") was added to .github/workflows/ci.yml's vitest job: it fails CI if any tests/smoke-playwright/ spec reintroduces a .catch()-swallowed state: 'detached' wait sharing one of the hard-timeout-defining budget constants (WARM_API_RESPONSE, WARM_PAGE_HYDRATION, MUTATION_LONG, etc.) instead of its own small dedicated literal timeout.
Rule: if a wait is genuinely optional (a .catch()-swallowed best-effort check), give it its own small dedicated timeout literal (2–3s) — never a constant that also defines a hard-timeout assertion's budget elsewhere. dashboard-goal-create.spec.ts's badgeOverlay/keepGoingBtn waits already follow this correctly (own literal 3_000/5_000 timeouts on elements that genuinely do get removed from the DOM) and are the reference pattern.
Diagnosed: OBJ-1605 (Dave, reading CI trace/screenshots for run 29793589690 rather than reproducing locally). Fixed by Tess, PR #1668 (journey.spec.ts + .github/workflows/ci.yml, commit 72f469f53). Reviewed by Roy.
Gotcha 24 — AchievementWatcher's global USER_QUERY poll can inherit a stale public_id from a different account's storageState, producing continuous FORBIDDEN errors for the full duration of a fallback-token run
Applies to: ionic_frontend/src/router/index.ts (syncClerkUser()), ionic_frontend/src/components/achievements/AchievementWatcher.vue (mounted globally in App.vue, not gated by route.meta.onboarding), rails_api/app/graphql/resolvers/user_queries.rb#user.
Found while diagnosing Gotcha 23's onboarding timeout (OBJ-1605): during a fallback-token smoke run, every single AchievementWatcher USER_QUERY poll (fires every 10s throughout the whole run, including onboarding) returned GraphQL FORBIDDEN. user_queries.rb#user returns FORBIDDEN unless current_user.public_id == id or the caller is admin — so the watcher was polling with the wrong id.
Root cause: syncClerkUser()'s fast path in router/index.ts (if (existingId) { ...; setSynced(existingId, ...); return }) trusts Session.decoded()?.id — i.e. the objectuve_user_public_id key in localStorage — without validating it against the currently-active auth mode. In a Playwright run, that key is inherited from the setup project's own baseline storageState, which was written for a different account than the fallback-token account actually authenticating this specific run. The fast path short-circuits on that stale id instead of running SYNC_USER_MUTATION, so AchievementWatcher polls USER_QUERY with an id that never matches current_user.
Consequence: continuous unnecessary network round-trips, Sentry.captureException calls, and Apollo console warnings for the entire duration of any fallback-token-driven session (staging smoke today; presumably any real fallback-token/magic-link flow in the app, not just tests) — real but secondary timing pressure on top of whatever else is running.
Status: fix tracked separately as OBJ-1607 (not bundled with Gotcha 23's timeout fix — unrelated app-code bug vs. test-file bug). As of this writing OBJ-1607 has an open PR (#1667) not yet merged; the options on the table are (a) always run SYNC_USER_MUTATION for fallback/magic-link auth, or (b) validate the cached id against the active auth mode before short-circuiting the fast path. Update this entry once the fix lands and note the actual approach taken.
Diagnosed: OBJ-1605 (Dave, found while investigating Gotcha 23). Fix in progress: OBJ-1607, PR #1667.
Gotcha 25 — A stale/invalidated storageState Clerk session surfaces as dozens of unrelated-looking UI timeouts across an entire chromium run, not as an auth error
Applies to: the chromium project's non-journey.spec.ts specs (every spec that relies solely on storageState: AUTH_FILE for auth — communities, coach/enneagram, dashboard, gamification, goals-habits, monetization, platform); fixtures/smoke.ts; setup/auth.staging.setup.ts.
Staging Playwright smoke on PR #1762 (OBJ-1729) failed 45/78 chromium specs while mobile-safari and mobile-android passed 100%. The failures spanned totally unrelated features (communities, achievements, coach, checkout, settings, ally-discovery) with no shared code path, and the merged diff only touched a backend interaction (AddGoalEvent) with no auth surface — so this read, at first glance, like a wall of unrelated product regressions.
Root cause (confirmed via the uploaded error-context.md snapshots, not just locator-timeout messages): every single failure's captured DOM snapshot showed the Clerk sign-in page ("Welcome back... Sign in with Google"), not the target route. journey.spec.ts was unaffected because it never relies on storageState for auth — every test in it injects its own fallback token via page.addInitScript. Every other chromium spec depends purely on the Clerk session captured once by auth.staging.setup.ts into storageState, shared across chromium/mobile-safari/mobile-android (playwright.config.ts — one setup/warmup pair, not per-project). Once that session stopped validating, every spec that depends on it failed identically — not because of anything wrong with the page under test, but because the router's auth guard bounced every one of them to /sign-in. The failures started ~9 minutes into the chromium run and lasted for the run's entire remaining ~43 minutes (mobile-safari/mobile-android, which ran afterward against the same storageState file, passed 100% — whatever broke had resolved by the time they ran).
Why this took real digging to see: each failing spec's own locator (getByText('Your Coach'), [data-testid="dashboard-greeting"], [role="group"][aria-label="Filter by category"], etc.) reported a normal-looking toBeVisible/toBeAttached timeout — indistinguishable, from the reported error message alone, from a real per-page bug. Only reading the actual page snapshot in each failure's error-context.md (not just the assertion text) revealed the shared cause.
Fix (this issue, PR TBD): fixtures/smoke.ts's page fixture now checks, after any failing test, whether page.url() landed on /sign-in; if so it attaches a testInfo annotation naming the storageState session as the likely cause instead of the page under test. This doesn't prevent the underlying session flake (that's an environment condition, not a test-file bug) — it turns a future occurrence from "45 mysterious unrelated failures" into one line pointing straight at the real cause.
Not fixed here (deliberately out of scope — would need a dedicated respec, not a 4th round on this ticket): why the shared Clerk session stopped validating for ~43 minutes on this specific run and recovered on its own. Candidates not yet ruled in or out: Clerk FAPI refresh requires the Turnstile-bypass route interceptor that only auth.staging.setup.ts's own context registers (setupClerkTestingToken) — no browser context created for the actual spec runs has it; and/or overlap with the next commit's (ce745adb, PR #1763) own Staging Deployment redeploying enkidu-api-staging/the Firebase frontend mid-run (confirmed to overlap the failure window's final ~7 minutes, but not its first ~35). If this recurs, capture Clerk-side request/response logs (not just the app's DOM state) during the failure window before attempting a fix.
Diagnosed: OBJ-1729 (Tess, reading gh run view --log-failed plus the downloaded playwright-smoke-artifacts error-context.md snapshots — the DOM snapshot, not the assertion message, was what actually revealed the cause).
Gotcha 26 — clerk-sign-up.spec.ts's OTP-input scoping resolved to a Clerk container with zero real inputs on mobile-safari, hanging an un-timed click() for the whole test budget
Applies to: ionic_frontend/tests/smoke-playwright/specs/auth-onboarding/clerk-sign-up.spec.ts, the mobile-safari Playwright project.
OBJ-2014: staging mobile-safari failed clerk-sign-up.spec.ts on 4 of 5 sampled runs (30708737028, 30713285657, 30714672010, 30715658506), always the same signature — locator.click: Test timeout of 240000ms exceeded at the OTP-entry step, both the original attempt and its retry running the full ~4.0-4.1m before failing. chromium and mobile-android never failed.
Root cause (confirmed via the failing run's own trace.zip — screenshots + action log, not just the assertion message): the spec scoped its OTP-segment locator to .cl-signUp-emailCodeVerification, [data-localization-key*="emailCode"] before searching for input[name^="codeInput-"], input[autocomplete="one-time-code"] inside it. On mobile-safari, that scoped container resolved to something (.count() > 0), but a mid-run screenshot at the moment the click started waiting shows the 6-box code-entry UI already fully rendered and visible on screen — while the scoped input lookup inside whatever .first() matched kept returning zero elements for the entire remaining budget (confirmed via the trace's action log: a single waiting for locator(...) line and then total silence for ~178s, the signature of a locator that never resolves to any element at all, not one that's present-but-not-actionable). Chromium's console output for this same page independently confirms the underlying instability: Clerk itself warns Structural CSS detected that may break on updates for this app's .cl-* class usage — exactly the kind of internal, version-fragile selector the scoping depended on. Whatever .first() actually matched on mobile-safari, it did not contain the real code-input elements as descendants, and the else branch's fallback then called .click() on a locator with zero matches — Playwright's actionability wait for zero elements silently absorbs the rest of the enclosing test timeout with no incremental log output, surfacing as a generic, undiagnosable locator.click: Test timeout exceeded pointing at the click line rather than at the real cause two steps earlier.
Fix (this issue): dropped the container scoping for the OTP-segment locator — input[name^="codeInput-"], input[autocomplete="one-time-code"] is specific enough on the sign-up page on its own (verified no collision with the username/email/password/Turnstile-hidden inputs also present on the page) — and added an explicit .waitFor({ state: 'visible', timeout: WARM_PAGE_HYDRATION }) before branching, so a genuine "code step never rendered" break now fails fast with a clear waitFor timeout instead of silently eating the rest of CLERK_SIGNUP_SPEC's 240s budget on an un-timed click(). The narrower codeContinue button lookup keeps its container scope — a miss there just skips the click (isVisible().catch(() => false)) rather than hanging, so the same fragility isn't a correctness risk in that spot.
General rule: never let a .click()/.fill() on a not-yet-confirmed-to-exist locator inherit the enclosing test's full remaining timeout as its implicit wait — add an explicit, bounded waitFor immediately before it (or right after any .count()-based branch decision) so a genuine "element never appeared" failure is diagnosable at the point of absence, not wherever the clock happens to run out inside a downstream helper call. This applies doubly to any locator scoped inside a third-party component's internal/undocumented CSS classes (Clerk's own .cl-* classes, in this case) — those are explicitly unstable across releases and viewports and are a .count() > 0 check away from silently degrading a "found nothing" case to "matched the wrong, input-less element."
Guardrail: n/a — one-off (the fragile .cl-*/data-localization-key scoping pattern this fix removed does not recur elsewhere in tests/smoke-playwright/; the remaining use in this same file, codeContinue's lookup, is non-hanging by construction). If a future spec reintroduces the same "scope-then-click-with-no-wait" shape against a .cl-* selector, that would be the trigger to add a CI lint guard.
Diagnosed: OBJ-2014 (Tess, reading gh run view --log-failed plus the downloaded playwright-smoke-artifacts trace.zip for run 30715658506 — the trace's screenshots and action log, not just the reported assertion, were what revealed the OTP UI was already rendered while the scoped locator matched nothing).
Gotcha 27 — registerFlagOverrides only reaches the client; a flag also checked server-side via FeatureFlagService.enabled? is untouched by it
Applies to: any spec that calls registerFlagOverrides (ionic_frontend/tests/smoke-playwright/helpers/flagOverrides.ts) to force a PostHog flag on/off for a mutation or query whose resolver also gates on that same flag server-side.
registerFlagOverrides intercepts only this browser context's own PostHog /flags fetch — it makes the client believe a flag is on or off, nothing more. It has no effect on FeatureFlagService.enabled? (rails_api/app/services/feature_flag_service.rb), which several GraphQL mutations/resolvers call independently, hitting real PostHog server-side keyed on the account's own distinct_id (user.public_id). If a spec forces such a flag on client-side and then asserts on the branch the flag is supposed to unlock, it is asserting on a precondition the spec never actually established — at the flag's real rollout percentage, the backend can silently take the opposite branch while the client believes otherwise, and the spec is exercising the wrong code path without any signal that it's doing so.
This is not one call site — check before writing the spec. FeatureFlagService.enabled? currently gates well over a dozen backend call sites across mutations, resolvers, jobs, and services, including async_ai_delivery_enabled (all seven AI mutations), seasonal_events_enabled, teams_leaderboards, teams_notifications, privacy_controls_v1, connected_apps_enabled, discovery_ranking_v2, teams_collective_goals, and more. Before writing a smoke spec that forces one of these flags via registerFlagOverrides, check whether the resolver it's meant to unlock also gates server-side:
grep -rn "FeatureFlagService.enabled?('<flag-key>'" rails_api/appIf it does, registerFlagOverrides cannot make that spec exercise the flag-ON backend path deterministically — there is currently no mechanism anywhere in this repo to force FeatureFlagService.enabled?'s answer for a specific account (that would require either a designated smoke/test account carved into PostHog's release conditions, or a server-side test-only override — neither exists today). Writing an assertion that depends on the server-side branch is writing an assertion on an unverified precondition.
Diagnosed: OBJ-3999 — coach-reconnect-grace.spec.ts (UI-SPEC R4) forced async_ai_delivery_enabled on client-side and asserted on the async-delivery code path (Ai::DispatchAiRequestJob dispatching, then a mid-flight socket drop). Mutations::Ai::GetAdvice#resolve (rails_api/app/graphql/mutations/ai/get_advice.rb:46) independently calls FeatureFlagService.enabled?('async_ai_delivery_enabled', user: context[:current_user]) against real PostHog. At the flag's real 0% rollout, the demo/smoke account always took resolve_sync — a full, blocking LLM call — so no AiRequest was ever dispatched and R4's own code path was never reached. The observed staging failure (coach-send-loading never visible within WARM_API_RESPONSE, timing out before setOffline() even ran) was resolve_sync's real LLM latency outrunning a timeout budget sized for "dispatch + short wait-ladder mount" — not a reconnect-grace regression in useAiRequest.ts or the transport layer.
Fix applied: Tess quarantined the spec (test.skip(true, SERVER_SIDE_FLAG_GAP_SKIP_REASON), commit 41e7dbf0c) rather than assert on a precondition the spec cannot establish, with the full reasoning captured inline in both the file header comment and the skip-reason constant. Do the same for any other spec that hits this shape — quarantine with a skip reason naming the flag and the server-side call site, don't try to work around it with a longer timeout or a looser assertion, since the spec isn't flaky, it's testing the wrong branch entirely. Re-enable once a genuine server-side override for a designated smoke/test account exists.
Gotcha 28 — registerServerFlagOverrides is inert on the real production lane, but not on Firebase preview channels
Applies to: rails_api/app/controllers/concerns/smoke_flag_overrides.rb, ionic_frontend/tests/smoke-playwright/helpers/flagOverrides.ts (registerServerFlagOverrides), any spec that needs a server-side FeatureFlagService.enabled? gate forced on/off (see docs/development/feature-flags.md for the mechanism itself).
SmokeFlagOverrides#apply_smoke_flag_overrides gates on Rails.env.production? first — before parsing the X-Smoke-Flag-Overrides header, before checking X-Smoke-Secret, with no log line. On the real production lane, registerServerFlagOverrides is a structural no-op: the headers arrive, the API ignores them, and any FeatureFlagService.enabled? call still resolves against live PostHog.
The trap: smoke_env=production does not always mean "hits the real production API." Gotcha 1 above documents that Firebase preview-channel URLs (*.web.app) route through smoke_env=production in playwright-smoke.yml's "Resolve smoke environment" step — purely so auth.production.setup.ts's fallback-token auth runs instead of a Clerk browser flow those domains can't complete. That step only picks the auth setup file. It does not determine which backend the run actually talks to. Per playwright-smoke.yml's "Resolve URLs" step (OBJ-1657), Preview PRs always pass an explicit api_url pointing at the shared staging API (https://staging.api.objectuve.com, set unconditionally in preview.yml — Preview never deploys its own backend). Staging Rails runs RAILS_ENV=staging (rails_api/config/environments/staging.rb), so Rails.env.production? is false there — registerServerFlagOverrides works normally on a Preview-lane run, despite smoke_env reading production.
The override is only truly inert on the run that resolves api_url to https://api.objectuve.com — i.e. inputs.environment == "production" with no api_url override, the real post-tag production smoke run.
| Lane | smoke_env | Actual API hit | Rails.env.production? | registerServerFlagOverrides |
|---|---|---|---|---|
Staging (post-merge to master) | staging | staging.api.objectuve.com | false | Works |
Preview channel (PR, *.web.app) | production (auth routing only, Gotcha 1) | staging.api.objectuve.com (OBJ-1657, shared) | false | Works |
| Real production (post-tag) | production | api.objectuve.com | true | Inert — no-op |
What this means for a new server-gated spec: don't infer "the override won't apply here" from smoke_env alone — check which API URL the run actually resolved (steps.urls.outputs.api_url in the workflow, or SMOKE_API_URL in the job env). A spec relying on registerServerFlagOverrides runs correctly on staging and on every PR preview channel; it only degrades on the genuine production lane. If such a spec needs to run there too, assert the dual-outcome .or() pattern from Gotcha 10 rather than assuming the override took effect, or skip the server-forced precondition on that one lane.
Diagnosed: OBJ-4005 (Dori, docs task for OBJ-4002's server-side flag override mechanism) — traced against playwright-smoke.yml's "Resolve URLs"/"Resolve smoke environment" steps and rails_api/config/environments/staging.rb while documenting the new mechanism, not a live incident.
Related
.github/workflows/playwright-smoke.yml— env routing logicionic_frontend/tests/smoke-playwright/setup/auth.production.setup.tsionic_frontend/tests/smoke-playwright/setup/auth.staging.setup.tsionic_frontend/tests/smoke-playwright/specs/auth-onboarding/journey.spec.tsionic_frontend/tests/smoke-playwright/specs/auth-onboarding/10-existing-user-not-re-onboarded.spec.tsionic_frontend/tests/smoke-playwright/specs/coach-enneagram/enneagram-assessment.spec.ts— reference implementation of the URL-check pattern (Gotcha 4)ionic_frontend/tests/smoke-playwright/specs/goals-habits/dashboard-goal-create.spec.ts— reference implementation of the WelcomeBack + BadgeModal guard pattern (Gotcha 5)ionic_frontend/tests/smoke-playwright/helpers/api.ts—apiResetOnboardinghelperrails_api/app/graphql/mutations/reset_onboarding.rb— the mutationionic_frontend/src/composables/useOnboardingGate.ts—shouldOnboard/isBypassAuthionic_frontend/src/router/index.ts:424— D-05/welcome/*exemptiondocs/development/playwright-smoke-secrets-gotcha.md— required GitHub Actions secrets- OBJ-644 — added
*.web.app→smoke_env=productionrouting - OBJ-700 — re-planned and shipped deterministic state reset (PR #924, v3.9.270)
- OBJ-704 — CI re-run investigation that surfaced the goal-seeding gap (Gotcha 3) and coach persona redirect (Gotcha 4)
- OBJ-744 — Added self-healing goals guard to
auth.staging.setup.ts(mirrors the production guard) - OBJ-755 — Dave's root-cause diagnosis for the BadgeModal overlay failure (Gotcha 5)
- OBJ-756 — Fix 3 smoke failures including BadgeModal guard (PR #955)
- OBJ-781 — Dave's root-cause diagnosis for
ion-router-outletstrict-mode failures across 5 PRs (Gotcha 6). Fixed in PR #962. - OBJ-706 — Smoke hardening: WAAPI patch,
seen_achievementsseeding, coach-persona stacked-instance scoping (Gotchas 7–9). Fixed in PR #922. - OBJ-847 / OBJ-851 / OBJ-854 — Flag-gated route
.or()dual/triple-outcome sentinel pattern (Gotcha 10): empty-state miss fixed in8a68e6781; async-guard race structural fix PR #974, cherry-picked to master PR #1009. - OBJ-865 — Masked downstream serial failures; fixing an upstream test surfaces pre-existing failures as fake regressions (Gotcha 11). Diagnosed by Dave on PR #996.
- OBJ-866 — USER_QUERY staging-vs-preview field cascade + data-state-dependent assertions (Gotcha 12). From OBJ-812 / PR #979.
- OBJ-852 — Smoke-rebase-drift: stale assertion timeouts after rebasing a branch cut before master's cold-start hardening (Gotcha 13). PRs #974 and #979.
ionic_frontend/tests/smoke-playwright/specs/platform/pending-ally-requests.spec.ts— reference implementation of the.or()triple-outcome flag-guard pattern incl. empty state (Gotcha 10)ionic_frontend/tests/smoke-playwright/specs/platform/ally-discovery.spec.ts— canonical two-outcome.or()reference for ally flag routes (Gotcha 10)ionic_frontend/src/views/welcome/slides/CoachPersonaSlide.vue— slide 6 source;<Transition>removed (see Gotcha 9 anddocs/features/onboarding-coach-builder.md)- OBJ-895 — Dependabot PRs skip
cypress-e2etier (Gotcha 15). Fixed in PR #1062. - OBJ-1024 — Attempted uniform hard-blocking promotion (3 rounds); confirmed advisory-on-ephemeral is durable (Gotcha 16). Josh decision 2026-07-03.
docs/development/playwright-smoke-gate.md— Smoke surface by environment, full gate configuration, and when to revisit the advisory design.- OBJ-1088 — Four pre-existing staging smoke failures, red since 2026-06-19: Clerk sign-up username/password requirement,
GoalCreate.vuehero-phase default,roadmap-capstonetext collision,meet-coach.vuedesktop/mobile DOM-both-present collision, and a category-chip hardcoded-name drift (Gotcha 17). Tess R1-R3, PR #1248. ionic_frontend/src/views/GoalCreate.vue—phase: 'hero'default (OBJ-1082);?source=wizarddeep-link skips to the manual form (Gotcha 17b)ionic_frontend/src/components/goal/RoadmapCapstone.vue/ionic_frontend/src/components/goal/GoalHero.vue— the goal/habit-name text collision on goal detail pages (Gotcha 17c)ionic_frontend/src/views/MeetCoach.vue— desktop heading (hidden lg:block) / mobile progress bar (lg:hidden) both DOM-present in 'editing' wizard mode (Gotcha 17d)ionic_frontend/src/views/Communities.vue/ionic_frontend/src/components/communities/CommunityCategoryChip.vue—dynamicCategoriescomputed; category chips are data-derived, not a fixed enum (Gotcha 17)- OBJ-1140 —
demo:resetCloud Run Job ran fire-and-forget, racing the smoke run's dashboard queries and trippinghasErroron03b-habit-create(Gotcha 19). Dave's diagnosis (H1–H6), Tess's--waitfix, PR #1264 (squash SHA20344f52f). ionic_frontend/src/views/Dashboard.vue—hasErrorcomputed (Dashboard.vue:769);v-else-if="hasError"error-state branch (Dashboard.vue:124-128) (Gotcha 19)- OBJ-1082 — Broken
.not.toContainText()-on-a-conditional-element regression guard added by PR #1264, fixed by PR #1266 (Gotcha 18) — not this issue, but the guard bug blocks OBJ-1140's watch window from starting. ionic_frontend/tests/smoke-playwright/specs/platform/settings-profile-photo-upload.spec.ts— staging-only, chromium-only spec exercising thestorage.objectAdminupload+delete GCS grant (Gotcha 20)rails_api/app/interactions/user_identity/update_user_photo.rb/update_user.rb— no standalone photo-delete mutation; deletion only viaupdateUser(deleteUser: true)cascadingUser#photo dependent: :destroy(Gotcha 20a)rails_api/lib/tasks/smoke_accounts.rake—ensure_photo_upload_account;User.with_deletedreconciliation for a disposable account a spec soft-deletes mid-run (Gotcha 20b)- OBJ-1391 — Verified image upload+delete against the new runtime SA's
storage.objectAdmingrant ongs://enkidu-storage-staging; closed v4.5 Phase 2 acceptance criterion 5 (Gotcha 20). Tess, PR #1500. ionic_frontend/src/apollo-client.ts—createUploadLink(apollo-upload-client); whyUpload-scalar mutations go out asmultipart/form-data, not JSON (Gotcha 21)- OBJ-1391 (R2) —
waitForResponsepredicate matched onpostData()for a multipart upload request, which Playwright always reports asnull; fixed by matching on the parsed response body instead (Gotcha 21). Orion's diagnosis, Tess's fix, Roy's review, PR #1502. - OBJ-1605 — Six no-op
.first().waitFor({state:'detached'})waits injourney.spec.tsinherited OBJ-1566'sWARM_API_RESPONSEbump and exhausted02-onboarding's budget, misattributed as a coach-rhythm (slide 9) hang (Gotcha 23). Dave's diagnosis, Tess's fix + CI guardrail, PR #1668. - OBJ-1607 —
syncClerkUser()'s fast path trusts a stalepublic_idinherited fromstorageState, causingAchievementWatcher'sUSER_QUERYpoll to returnFORBIDDENfor the whole duration of a fallback-token session (Gotcha 24). Found by Dave alongside OBJ-1605; fix in progress, PR #1667. ionic_frontend/src/router/index.ts—syncClerkUser()fast-path id trust (Gotcha 24)ionic_frontend/src/components/achievements/AchievementWatcher.vue— globalUSER_QUERYpoll, not gated byroute.meta.onboarding(Gotcha 24)- OBJ-3999 —
registerFlagOverridesis client-only;coach-reconnect-grace.spec.tsquarantined after asserting on a server-sideFeatureFlagService.enabled?branch it could never actually force (Gotcha 27). Tess, commit41e7dbf0c. ionic_frontend/tests/smoke-playwright/helpers/flagOverrides.ts—registerFlagOverrides/clearPostHogPersistence, client-side-only PostHog/flagsintercept (Gotcha 27)rails_api/app/services/feature_flag_service.rb— the server-side flag gateregisterFlagOverridescannot reach (Gotcha 27)rails_api/app/graphql/mutations/ai/get_advice.rb:46— theasync_ai_delivery_enabledcall site that surfaced OBJ-3999 (Gotcha 27)docs/development/feature-flags.md— flag lifecycle, registry, and the client/server read patterns Gotcha 27 sits alongside- OBJ-4002 / OBJ-4005 — added the server-side override mechanism Gotcha 27's own "Re-enable once a genuine server-side override... exists" note was waiting on (Gotcha 28)
docs/development/feature-flags.md— "Overriding flags in smoke tests":registerFlagOverrides(client-side) vs.registerServerFlagOverrides(server-side, request-scoped, production-inert) (Gotcha 28)rails_api/app/controllers/concerns/smoke_flag_overrides.rb— theRails.env.production?gate this gotcha traces (Gotcha 28)- OBJ-4002 / OBJ-4005 — added the server-side smoke flag override mechanism and documented its production-lane-vs-Preview-channel inertness split (Gotcha 28).
Last updated: 2026-09-19 — added Gotcha 28 (registerServerFlagOverrides production-inertness vs. Firebase preview channels, OBJ-4005).
Prior update: 2026-08-03 (atlas g1) — fixed stale subdirectory citations for AchievementWatcher.vue.