Skip to content

Testing Guide

This document covers all testing infrastructure in the Enkidu (Objectuve) monorepo, including how to run tests, current coverage, CI integration, and expectations when adding new features.

For a point-in-time audit of suite health — risk-scored gaps and the remediation roadmap — see testing-audit.md (OBJ-920).

Table of Contents


Testing Layers

LayerToolScopeEnvironment
Backend unit/integrationRSpecModels, interactions, GraphQL, jobsLocal PostgreSQL
Frontend unitVitestComponents, composables, storesjsdom
E2E (mocked)Cypress6 maintained specs with mocked APILocal Vite dev server
Smoke (real API)PlaywrightFull-stack user journeysDeployed environments
Load (performance)k6Mutation/query throughput, webhook, healthStaging only, monthly cron
Visual/component docsStorybookComponent rendering and statesLocal Storybook server

Current Coverage

As of 2026-08-17 (commit 9f745d5c6, CI run #32079020137). These figures move with every merge to master — treat them as a snapshot, not a guarantee, and prefer a fresh CI run or local regen over trusting this page long-term.

Backend — 94.09% line coverage

Measured by SimpleCov (rails_api/coverage/), merged across the 3 CI shards by bin/assert_merged_coverage (rspec-coverage job — see CI/CD Integration). Run bundle exec rspec locally to regenerate the HTML report at rails_api/coverage/index.html (single-process run; won't match the merged-shard figure exactly).

Frontend — 83.09% statements / 84.73% lines

Measured by v8 via Vitest. Run npm run test:cov in ionic_frontend/ to regenerate.

CategoryStatementsBranchesFunctionsLines
All files83.09%79.47%77.78%84.73%
Components (src/components)84.30%81.62%79.26%86.40%
Composables (src/composables)90.17%81.94%87.98%91.52%
UI components (src/components/ui)79.43%77.84%83.27%80.86%
Helpers (src/helpers)92.25%88.34%92.30%93.44%
Stores (src/stores)95.50%83.67%100%96.05%

Phase 4 Coverage Gate (T6) — May 2026 (historical)

The Phase 4 Quality Assurance sprint (OBJ-265, May 2026) set a focused Vitest/v8 line-coverage gate on four composables supporting partnerships, community challenges, streak repair, and welcome-back flows. Gate outcome at the time, compared against current coverage (npm run test:cov, verified 2026-08-17):

ComposableLine Coverage (May 2026)Line Coverage (current)
usePartnership.ts90.47%90.47%
useCommunityChallenges.ts98.33%67.85%
useStreakRepair.ts100%90.24%
useWelcomeBack.ts100%96.87%

None of these four composables carries a dedicated per-file coverage gate in vite.config.ts today (unlike, e.g., useServiceWorker.ts or useCommunityAllyInvite.ts) — three have since drifted below their May 2026 figures as the surrounding features evolved. Treat the May 2026 column as a historical snapshot, not a live guarantee.

Backend: the SimpleCov Phase 4 filter group in rails_api/spec/spec_helper.rb scopes UserAlly, CommunityChallenge, ChallengeParticipant, social partnership interactions, IncrementChallengeProgress, AddGoalEvent, WelcomeBackOfferResolver, and StreakRepairOfferResolver.

Offline check-in coverage (deferral resolved): the original May 2026 gate deferred syncStore / useOfflineMutation unit coverage pending a feat/offline-checkins branch merge. That work landed directly on master instead (OBJ-31/32, "Offline-First Check-ins", 2026-05-11–12) — syncStore.ts (tests/unit/stores/syncStore.spec.ts) and useOfflineMutation.ts (tests/unit/composables/useOfflineMutation.spec.ts) both carry dedicated unit coverage today; syncStore.ts measures 94.33% line coverage as of this writing.


Backend Testing (RSpec)

Quick Reference

bash
cd rails_api

# Run all specs
bundle exec rspec

# Run by category
bundle exec rspec spec/models
bundle exec rspec spec/interactions
bundle exec rspec spec/graphql
bundle exec rspec spec/requests
bundle exec rspec spec/jobs

# Run a single file
bundle exec rspec spec/models/user_spec.rb

# Run a single test by line number
bundle exec rspec spec/models/user_spec.rb:42

# Linting
bundle exec rubocop

Spec Inventory (590 spec files, ~4,800 examples)

Counts from find rails_api/spec -name '*_spec.rb' | wc -l and a grep-count of it/specify blocks (the latter is approximate — it doesn't account for shared-example multiplicities).

DirectoryFilesWhat's Tested
spec/interactions/149Business logic (sign-up, goal CRUD, mood logging, community ops, notifications, habit check-in, streak freeze, progress calculation, unified feed, Teams)
spec/requests/125Integration tests through the GraphQL endpoint
spec/graphql/93Mutation and type specs
spec/models/78Validations, associations, scopes, custom methods
spec/services/62Service objects, achievement calculations, AI coach check-in prompts, TeamAccessPolicy
spec/jobs/59Background jobs (level-up, reminders, feed generation, habit streaks, AI check-in prompts)
spec/lib/7Rake task / library helpers
spec/mailers/5Email delivery
spec/controllers/3Controller-level tests
spec/migrations/, spec/db/, spec/tasks/, spec/config/2 eachMigration, database-level, rake task, and config concerns
spec/support/1Shared support helpers

Wave 4a (OBJ-941) partitions this suite into 3 CI matrix shards via bin/partition_specs — see CI/CD Integration.

Factories

71 FactoryBot factories in spec/factories/. Key factories:

ruby
create(:user)                    # Standard user (email, password, name)
create(:user, :admin)            # Admin user (trait not factory — check user factory)
create(:goal, user: user)        # Goal with required associations
create(:goal, user: user, recurrence_type: 'daily') # Habit goal
create(:goal_event, goal: goal)  # Progress update
create(:habit_completion, goal: habit) # Habit check-in record
create(:community)               # Community
create(:mood_log, user: user)    # Mood check-in
create(:user_action)             # Gamification tracking

Fake data is generated with the ffaker gem — never hardcode test data.

Configuration

  • spec/spec_helper.rb — SimpleCov setup, RSpec core config
  • spec/rails_helper.rb — Rails integration, transactional fixtures, FactoryBot syntax, Shoulda-Matchers
  • .rspec--require spec_helper
  • Coverage — SimpleCov with rails profile, filters /spec/, /config/, /vendor/

Key Gems

  • rspec-rails — test framework
  • factory_bot_rails — test data
  • ffaker — fake data generation
  • shoulda-matchers ~> 5.0 — concise model matchers
  • simplecov — coverage reporting
  • rubocop-rspec — RSpec style linting

Frontend Unit Testing (Vitest)

Quick Reference

bash
cd ionic_frontend

# Run tests in watch mode
npm run test:unit

# Single run (CI mode)
npm run test:unit -- --run

# With coverage report
npm run test:cov

# Run a specific file
npx vitest run tests/unit/composables/useTheme.spec.ts

Test Inventory

  • 507 test files (469 *.spec.ts + 38 *.test.ts) with ~5,950 tests total (grep-counted it/test blocks — approximate)
  • Located in tests/unit/ with subdirectories:
    • components/ — 312 specs (component rendering and interaction tests: GoalListItem, GoalCard, MoodSelector, NotificationsPanel, Teams components, etc.)
    • views/ — 71 specs (full-page view tests)
    • composables/ — 74 specs (useTheme, useAiCoach, useAiInsights, useGoalFab, useMenuState, useNotifications, useProfileCompletion, useProgressAnimation, useScrollAnimation, usePhotoGallery, etc.)
    • play/ — 12 specs
    • share/ — 8 specs (celebration share-card kit, v4.56)
    • lib/ — 7 specs
    • helpers/, constants/ — 6 specs each
    • router/ — 5 specs
    • services/ — 3 specs
    • stores/ — 2 specs
    • 9 top-level files (App.spec.ts, apollo-client.spec.ts, apollo-client.test.ts, posthog.spec.ts, etc.)

Configuration

Test configuration is embedded in vite.config.ts:

  • Environment: jsdom
  • Globals: enabled (describe, it, expect available without imports)
  • Path aliases: @, components, helpers, constants, stores

Test Environment Setup (tests/unit/setup.ts)

The setup file stubs 80+ Ionic components globally so tests don't need to import or mock them individually. It also mocks:

  • window.matchMedia — for dark mode detection
  • IntersectionObserver — for visibility tracking
  • requestAnimationFrame / cancelAnimationFrame

Patterns

Testing a composable:

typescript
import { useTheme } from '@/composables/useTheme'

describe('useTheme', () => {
  beforeEach(() => {
    localStorage.clear()
    document.documentElement.className = ''
  })

  it('defaults to light theme', () => {
    const { isDark } = useTheme()
    expect(isDark.value).toBe(false)
  })
})

Testing a component:

typescript
import { mount } from '@vue/test-utils'
import GoalCard from '@/components/GoalCard.vue'

it('renders goal name', () => {
  const wrapper = mount(GoalCard, {
    props: { goal: { name: 'Test Goal', ...defaults } }
  })
  expect(wrapper.text()).toContain('Test Goal')
})

E2E Testing (Cypress)

E2E tests validate frontend user flows with mocked GraphQL responses (no real backend needed).

Cypress and Playwright smoke are two deliberate, non-overlapping tiers — this suite is retained, not pending migration (resolved in OBJ-1843; see below).

Cypress is a 6-spec local mocked-backend tierally-flow, accessibility/a11y-smoke, auth/sign-in, goals/create-goal-with-past-attempt, goals/create-goal-with-identity, and goals/complete-goal-with-reflection. The cypress-e2e CI job runs npm run test:e2e with no --spec filter, so every spec matching cypress.config.ts's specPattern (tests/e2e/specs/**/*.cy.{js,jsx,ts,tsx}) executes — a prior hardcoded --spec allowlist had silently orphaned create-goal-with-identity from CI (zero signal, ever) until it was removed (OBJ-1843). Deployed-environment E2E lives in ionic_frontend/tests/smoke-playwright/ (9 suites) and is the canonical full-stack E2E layer for new deployed-env journeys — default new deployed-env E2E work to Playwright smoke (or, for pure component behavior, a Vitest component test); default new local mocked-GraphQL-contract or full-page a11y work to Cypress. 19 stale Cypress specs were retired under OBJ-568 once Playwright smoke covered their deployed-env behavior; the component-level assertions they uniquely held were backfilled into Vitest.

Cypress vs. Playwright: two tiers, not a migration

OBJ-1843 evaluated whether to migrate the Cypress suite to Playwright wholesale. Resolution: keep Cypress. The two suites test different things, not the same thing twice:

  • Cypress = local mocked-GraphQL contract + full-page a11y tier. It asserts exact mutation payload shapes (e.g. completionReflection, identityPrompt, pastAttemptContext, including the null-vs-omit distinction — see nullable-field-clear-vs-omit-gotcha.md) against a mocked backend, and runs cypress-axe WCAG 2.1 AA scans across assembled, routed pages.
  • Playwright smoke = deployed-environment health-check tier. Real Clerk sessions against a live SMOKE_BASE_URL, no request interception — it verifies the shipped app boots and core journeys resolve. It does zero GraphQL mocking today (no page.route interception of /graphql; the only context.route() calls inject the Clerk testing token).

Nothing in Playwright smoke currently duplicates Cypress's mocked-contract or full-page-a11y coverage, so there is no migration planned. @axe-core/playwright is an unused devDep in ionic_frontend/package.json — full-page a11y scans run via Cypress's cypress-axe, not Playwright; it's unwired, not a signal that a11y coverage is moving.

Quick Reference

bash
cd ionic_frontend

# Run all E2E tests (headless)
npm run test:e2e

# Open Cypress UI
npm run test:e2e:open

# Run by feature
npm run test:e2e:auth
npm run test:e2e:goals
npm run test:e2e:communities

Test Inventory (6 maintained specs)

SpecWhat's Tested
tests/e2e/specs/ally-flow.cy.tsAccountability-partner (ally) request/accept flow
tests/e2e/specs/accessibility/a11y-smoke.cy.tsaxe-core accessibility smoke across key screens
tests/e2e/specs/auth/sign-in.cy.tsSign-in flow with mocked GraphQL
tests/e2e/specs/goals/create-goal-with-past-attempt.cy.tsGoal creation with the optional past-attempt context field
tests/e2e/specs/goals/create-goal-with-identity.cy.tsGoal creation with the optional identity prompt field
tests/e2e/specs/goals/complete-goal-with-reflection.cy.tsGoal completion with the optional completion-reflection field

All six run in the cypress-e2e CI job (npm run test:e2e, no --spec filter — see above). New deployed-env flows belong in Playwright smoke (below); new pure-component behavior belongs in Vitest.

Configuration (cypress.config.ts)

  • Base URL: http://localhost:5173 (Vite dev server must be running)
  • Viewport: 1280x800
  • Command timeout: 8000ms
  • Retries: 2 in run mode, 0 in open mode
  • Video: disabled

Key Helpers

GraphQL Interceptor (tests/e2e/helpers/graphql-interceptor.ts):

Intercepts all POST /graphql requests and routes responses by operationName. Handles Apollo's BatchHttpLink (requests arrive as arrays).

typescript
setupGraphQL({
  SignInMutation: { data: { signIn: { session: { token: '...' }, errors: [] } } },
  UserQuery: { data: userData },
  GoalsQuery: { data: goalsData },
})

Auth Helper (tests/e2e/helpers/auth.ts):

cy.login() sets localStorage with a mock JWT to bypass the sign-in UI. Use for any test that starts from an authenticated state.

Fixtures (tests/e2e/fixtures/):

Mock data files for the maintained specs: user.json (and example.json). The feature-specific fixtures (goals, communities, etc.) were removed with the retired specs under OBJ-568.


Smoke Testing (Playwright)

Smoke tests run against real deployed environments (not mocked). They validate full-stack user journeys after each deployment.

The inventory below covers ionic_frontend/tests/smoke-playwright/. admin_dashboard/tests/smoke-playwright/ is a separate, staging-only suite for capturing UI evidence (not full smoke coverage) — see admin_dashboard/tests/smoke-playwright/README.md.

Quick Reference

bash
cd ionic_frontend

# Run against staging
SMOKE_ENV=staging npm run smoke:playwright:staging

# Run against production
SMOKE_ENV=production npm run smoke:playwright:production

# Interactive UI mode
npm run smoke:playwright:ui

# Manually trigger via GitHub Actions
# Go to Actions > "Playwright Smoke" > Run workflow > Select staging or production

Test Inventory (9 suites, ~33 specs)

Specs are independent — each runs in a fresh browser context with authentication persisted via storageState. Suites map to product areas:

SuiteCoverage
auth-onboardingSign-up, sign-in, onboarding flow
goals-habitsGoal creation, goal events, habit check-ins
dashboardDashboard states (empty, active, onboarding)
communitiesBrowsing and joining communities
gamificationXP, streaks, achievements, ranks
coach-enneagramAI coach and enneagram surfaces
mood-lifecycleMood logging and account lifecycle
monetizationSubscription and monetization flows
platformCross-cutting platform behavior

Configuration (ionic_frontend/playwright.config.ts)

  • Base URL / API URL: resolved from SMOKE_ENV (staging or production)
  • Timeouts: tuned for Cloud Run cold starts
  • Auth: persisted via Playwright storageState between specs
  • Retries: enabled in CI run mode

Architecture

  • No mocking — all requests go to the real deployed API
  • Fresh user per run — a unique test email is generated per run
  • Cleanup — the lifecycle suite deletes the test user via updateUser(deleteUser: true)
  • Independent specs — each spec runs in its own context; shared auth is loaded from a persisted storageState file rather than sequential Node-task state

CI Triggers

TriggerEnvironmentWorkflow
PR opened/updatedPreview deploypreview.yml -> playwright-smoke.yml
Push to masterProductionproduction.yml -> playwright-smoke.yml
Manual dispatchStaging or Productionplaywright-smoke.yml (workflow_dispatch)

On production, Playwright smoke gates the GitHub Release — the create-release job runs only after the smoke suite passes. On preview deploys results are advisory. See docs/development/playwright-smoke-gate.md for the full gate-severity breakdown by environment and current PR-merge-gate status.

For non-obvious behaviors that have caused repeated smoke failures — Clerk cold-start races, redirect-gate timing, staging/production auth divergence, and more — see docs/development/smoke-test-env-gotchas.md, the canonical smoke-env gotcha catalog. Read it before modifying auth.*.setup.ts, journey.spec.ts, or any spec that navigates to /dashboard.

Gotcha: Required secrets

The smoke suite requires four GitHub Actions secrets (CLERK_SECRET_KEY, FALLBACK_AUTH_SECRET, SMOKE_CLERK_EMAIL, SMOKE_FALLBACK_EMAIL) to be configured in the repository before the first CI run. Two more, ACHIEVEMENTS_EMPTY_EMAIL and ACHIEVEMENTS_POPULATED_EMAIL, are optional — matching the "Optional" framing in docs/operations/ci-secrets-checklist.md — because each consuming spec self-skips when the value is absent (test.skip(!email, ...) in specs/gamification/11-achievements-empty.spec.ts:7, 12-achievements-populated.spec.ts:7, and achievements.spec.ts:19,31) rather than failing. Two more still, CLERK_SECRET_KEY_PROD and VITE_CLERK_PUBLISHABLE_KEY_PROD, are needed only for the production-environment smoke run (smoke_env == 'production') that gates the GitHub Release. See docs/development/playwright-smoke-secrets-gotcha.md for the complete secret list, setup instructions, and why this matters (PR #812 merged while its CI was red due to a missing secret).

CI Secret Provisioning

When adding a Verify required secrets step to any GitHub Actions workflow, the secret must be provisioned in Repository Settings → Secrets and variables → Actions BEFORE merging the PR that adds the verify step. Provisioning after merge causes 100% of PR checks to fail until fixed.

Required secrets for Playwright smoke tests:

SecretFormatPurpose
SMOKE_CLERK_EMAILEmail with +clerk_test suffix (e.g. smoke+clerk_test@objectuve.com)Clerk test account for smoke auth. The +clerk_test suffix tells @clerk/testing to bypass Turnstile and accept the fixed OTP 424242.
CLERK_SECRET_KEYClerk API secret keyUsed for Clerk API operations during smoke tests.

Gotcha: Missing SMOKE_CLERK_EMAIL blocks every PR's smoke run immediately with Error: Secret not found. No CI passes until the secret is provisioned — this is why provisioning must happen before merging the workflow step that checks for it.

Gotcha: URL navigation and storageState

Two subtle traps surfaced during OBJ-576 that apply to any Playwright smoke setup using a Vue SPA with async router guards.

waitForURL(!includes('/sign-in')) is not a safe post-auth sentinel

Problem: After page.goto('/'), Playwright's waitForURL() with a negative check (!url.pathname.includes('/sign-in')) is satisfied immediately by the SPA root / — before any async Vue router guard has had time to fire.

Why it's subtle: In slower environments (Cloud Run staging, CI runners with cold starts), the router redirect to /welcome/start happens fast enough that waitForURL "lands" on /welcome and the check catches the right state. In faster environments (Firebase CDN preview channels), the page load event fires while the URL is still / and the async guard is still in flight. waitForURL resolves at / — not /sign-in, so the condition is satisfied — and your setup code continues with a URL that is neither the sign-in page nor the authenticated destination.

Scenario that bit us (OBJ-576): auth.staging.setup.ts signed the smoke user in, navigated to /, and called waitForURL((url) => !url.pathname.includes('/sign-in')). On Firebase preview channels, this resolved at the SPA root / before the onboarding gate fired. The completeOnboardingAndCreateGoal mutation branch (guarded by if (page.url().includes('/welcome'))) was never entered. The hard-assert fired: URL was /, not /dashboard.

typescript
// ❌ Don't do this — satisfies immediately at SPA root before router settles
await page.waitForURL((url) => !url.pathname.includes('/sign-in'))

// ✅ Do this — waits for the router to reach one of the two valid destinations
await page.waitForURL(
  (url) => url.pathname.startsWith('/dashboard') || url.pathname.startsWith('/welcome'),
  { timeout: 20_000 }
)

Rule: After navigating an SPA that has async router guards, always assert the expected destination explicitly, not the absence of the current page. !includes('/sign-in') passes immediately at any non-sign-in URL — including the root.

Applies to: Any Playwright test that calls page.goto() and then immediately reads page.url() or waitForURL with a negative predicate in a Vue SPA with async guards.

storageState captures incomplete-onboarding state silently

Problem: If the smoke account is fresh (onboarding_completed_at === null), the Vue router's evaluateOnboardingGate redirects every protected route (/dashboard, /settings, /achievements, /communities) to /welcome/start. Calling context().storageState() immediately after sign-in captures this broken redirect state. Every downstream spec that loads the storageState file inherits the redirect and times out at 30 seconds — with no error pointing back to setup.

Why it's subtle: The setup's waitForURL check can pass (at /welcome/start) without any error. There is no Playwright assertion failure. The storageState is written "successfully." Only 44 minutes later do you notice that 7+ specs all timed out waiting for elements that are never rendered because the router has already redirected them.

Scenario that bit us (OBJ-576): ensureStagingUser() created a fresh Clerk account with no goals. After sign-in, syncClerkUser() set onboardingCompletedAt = null. Every spec loading the storageState was redirected to /welcome/start. The cascade: clerk-sign-in, dashboard-accountability-partners, dashboard-todays-actions, dashboard-goal-create, supporter-overhaul, 08-achievements, communities-overhaul — all 30-second timeouts.

Fix: Before calling context().storageState(), assert that the URL is the expected post-onboarding destination. Also write any locally-cached onboarding state to localStorage after the completion mutation, to prevent the router's fast-path from reading a stale null value on the next navigation.

typescript
// After completing onboarding and navigating to /dashboard:

// Step 1: Sync the local cache (prevents router fast-path re-firing the gate)
await page.evaluate(() => {
  window.localStorage.setItem('objectuve_onboarding_completed_at', new Date().toISOString())
})

// Step 2: Navigate to the expected destination
await page.goto('/dashboard')
await page.waitForURL(/\/dashboard/, { timeout: 20_000 })

// Step 3: Hard-assert before writing storageState — loud failure > silent cascade
if (!page.url().includes('/dashboard')) {
  throw new Error(
    `staging setup: expected /dashboard before saving storageState, but URL is ${page.url()}. ` +
    `Onboarding may not have completed — check completeOnboardingAndCreateGoal.`
  )
}

await context.storageState({ path: 'playwright/.auth/smoke-user.json' })

Rule: Never call storageState() without first asserting the expected post-auth page. If the assertion fires, the error surfaces at setup (fast, loud, obvious) instead of as a 30-second timeout cascade across every downstream spec.

Applies to: Any smoke setup that uses a fresh user account and a redirect-gated destination.

Playwright setup project timeout with multi-step auth chains

Problem: Playwright's default 30-second test timeout applies to the entire test body, not per-operation. A setup project running a multi-step auth chain (ensureStagingUser + clerkSetup + page.goto + waitForFunction(Clerk.loaded)) in sequence can silently consume the 30-second budget before any assertion fires. On a staging cold-start, this is a guaranteed failure.

Why it's subtle: Each step is fast in isolation (2–5 seconds). Summed together, the chain can consume 20+ seconds. On slower environments (Cloud Run cold starts, CI runners under load), the remaining 5–10 seconds are insufficient for waitForFunction to resolve, and the timeout fires with no indication of which step consumed the budget — it just says "timeout at 30.0s."

Rule: Always set an explicit, generous timeout on setup projects with multi-step auth chains. The master branch now uses 180_000 (3 minutes) to provide ample headroom. Also, ensure the arguments to waitForFunction are correct; a common error is to pass the options object as the second argument instead of the third, which silently ignores the timeout.

typescript
// playwright.config.ts
export default {
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
      use: { ...devices['Desktop Chrome'] },
      timeout: 180_000,  // Explicit 3-minute timeout for multi-step setup
    },
    // ... regular projects with default 30_000
  ],
}

// auth.staging.setup.ts
await page.waitForFunction(
  () => (window as any).Clerk?.loaded === true,
  null, // IMPORTANT: 2nd arg is for polling, not options
  { timeout: 120_000 }
);

Symptom: Consistent timeout at exactly 30.0 seconds during setup, rather than a functional error or assertion failure.

Applies to: Any Playwright smoke setup that combines user provisioning, Clerk setup, navigation, and async guards in a single test body. See OBJ-653 (first identification, Clerk cold-start on preview channels) and OBJ-655 (deeper investigation).

@clerk/testing/playwright v2: call setupClerkTestingToken before navigation

Problem: clerkSetup() only sets environment variables (CLERK_FAPI, CLERK_TESTING_TOKEN). It does NOT register the Playwright context.route() interceptor that injects __clerk_testing_token into Clerk FAPI requests. Without that interceptor active during page load, Turnstile bot protection blocks the Clerk SDK's /v1/client init call from a headless CI browser — window.Clerk.loaded never becomes true, and waitForFunction times out regardless of how long you wait.

Why it's subtle: clerkSetup() looks like a full "Clerk setup" call. It is not. The route interceptor lives in a separate function, setupClerkTestingToken({ page }). Nothing in the timeout error output reveals that Turnstile is blocking — the symptom is identical to a cold-start timing issue, so increasing the timeout is the natural (wrong) first fix.

Rule: Import and call setupClerkTestingToken({ page }) after clerkSetup() and before any page.goto. Order matters:

  1. clerkSetup() first — sets CLERK_FAPI, which setupClerkTestingToken reads
  2. setupClerkTestingToken({ page }) second — registers the route interceptor on the page context
  3. page.goto(...) third — all Clerk FAPI requests made during and after page load are intercepted
typescript
import { clerkSetup, clerk, setupClerkTestingToken } from '@clerk/testing/playwright'

// In your setup test body:
await clerkSetup()                     // Sets CLERK_FAPI env var — route interceptor NOT yet active
await setupClerkTestingToken({ page }) // Registers context.route() interceptor — BEFORE goto
await page.goto('/sign-in')            // Clerk SDK loads; its /v1/client request is intercepted

The interceptor is idempotent — setupClerkTestingToken uses a WeakSet guard internally, so calling it again inside clerk.signIn() is a safe no-op.

Symptom: waitForFunction(() => window.Clerk?.loaded) times out consistently at the configured ceiling regardless of how high you set the timeout. Increasing the timeout makes no difference.

Applies to: Any Playwright smoke setup using @clerk/testing/playwright v2 where Turnstile bot protection is enabled in the Clerk instance settings. Reference: OBJ-653.

Two Clerk Keys in CI: Build-time vs. Runtime

Problem: Two different Clerk publishable keys are used in CI, and a mismatch between them can cause silent failures where the app loads but authentication never completes.

  1. VITE_CLERK_PUBLISHABLE_KEY: A GitHub Actions secret baked into the Vite bundle at build time. This key determines the CDN hostname from which the Clerk.js browser bundle is loaded.
  2. CLERK_PUBLISHABLE_KEY: An environment variable set in the playwright-smoke.yml workflow, used at runtime by the clerkSetup() function in Playwright tests.

Why it's subtle: If the build-time VITE_CLERK_PUBLISHABLE_KEY is corrupted or points to the wrong Clerk instance, the app will fail to load the Clerk.js bundle from the CDN. This often results in a DNS resolution error for a nonsensical hostname (e.g., npm). The app will appear to load, but window.Clerk will never be defined, and waitForFunction(() => window.Clerk?.loaded) will time out. The error gives no direct indication that a misconfigured secret is the root cause.

Rule: The VITE_CLERK_PUBLISHABLE_KEY (build-time) and CLERK_PUBLISHABLE_KEY (runtime) must both point to the same, valid Clerk instance for the environment under test.

Symptom: waitForFunction for window.Clerk.loaded times out. Network trace in Playwright artifacts shows a failed request to load clerk.browser.js from an incorrect hostname. See OBJ-655.

GitHub Actions Secret Scanning and Job Outputs

Problem: GitHub Actions has a security feature that automatically scans workflow logs and outputs for anything that looks like a secret. When it finds a potential secret (like a Firebase deploy URL containing a long, random-looking token), it replaces the value with ***. This can break downstream jobs that depend on that output.

Why it's subtle: A job can successfully deploy a preview environment and write the URL to its outputs, but the next job in the workflow receives an empty string instead of the URL. The failure appears in the consuming job, not the producing job, making it difficult to debug.

Rule: Never directly consume a job output that might be suppressed by secret scanning. Instead, implement a recovery mechanism. In our playwright-smoke.yml workflow, the Resolve URLs step does this:

  1. It first tries to use the direct inputs.frontend_url.
  2. If that's empty, it falls back to a RECOVERED_FRONTEND_URL which is extracted from the PR comment that the Firebase deployment action posts.
  3. It then sets its own output, steps.urls.outputs.frontend_url, which is safe to use in subsequent steps.

Fix: Always reference the resolved URL from the recovery step, not the raw input.

yaml
# WRONG - This will be empty if secret scanning is triggered
if [ -z "${{ inputs.frontend_url }}" ]; then
  exit 1
fi

# RIGHT - This uses the output from the recovery step
if [ -z "${{ steps.urls.outputs.frontend_url }}" ]; then
  exit 1
fi

Symptom: A workflow step fails with an "empty URL" or similar error, even though the previous step that generated the URL succeeded. See OBJ-655.

Vite Build-Time Guard for Clerk Keys

Problem: A corrupted VITE_CLERK_PUBLISHABLE_KEY secret can cause silent runtime failures in CI that are hard to debug.

Fix: A build-time guard has been added to ionic_frontend/vite.config.ts. This guard runs during the vite build step in CI. It decodes the base64 portion of the VITE_CLERK_PUBLISHABLE_KEY and checks if the resulting frontendApi value looks like a valid hostname (i.e., contains a .).

Why this helps: If the secret is malformed, the build will fail loudly and immediately with a clear error message, rather than proceeding to a hard-to-debug runtime timeout in Playwright.

typescript
// ionic_frontend/vite.config.ts
// Simplified logic
const key = process.env.VITE_CLERK_PUBLISHABLE_KEY;
if (key && key.startsWith('pk_')) {
  const payload = new Buffer(key.split('_')[2], 'base64').toString('utf8');
  const frontendApi = payload.split('$')[0];
  if (!frontendApi.includes('.')) {
    throw new Error(`Invalid VITE_CLERK_PUBLISHABLE_KEY: frontendApi "${frontendApi}" does not look like a hostname.`);
  }
}

Symptom this prevents: waitForFunction(Clerk.loaded) timeouts caused by a corrupted Clerk key. The build will now fail first. See OBJ-655.

Firebase Hosting returns HTTP 200 for 'Site Not Found'

Problem: Firebase Hosting's default 'Site Not Found' page returns HTTP 200 status instead of 404. Never poll just for response.status === 200 to confirm a preview deployment is serving content — an undeployed site looks "healthy" by status code alone. Check for app-specific markers instead (e.g. id="app" or equivalent).

Why it's subtle: A test that does page.goto('/'), then expect(page.status()).toBe(200) will pass on a Firebase 'Site Not Found' page. The SPA's async initialization functions (like window.Clerk.loaded) will never become true on the 'Site Not Found' page, regardless of timeout duration. Your waitForFunction(Clerk.loaded) then times out after 30 seconds with no indication that the deployment itself is stale or undeployed.

Rule: After navigating to a preview or deployed environment, check for an app-specific HTML element before asserting auth state:

typescript
// WRONG — passes on Firebase 'Site Not Found' page:
const response = await page.goto('/')
expect(response?.status()).toBe(200)
await page.waitForFunction(() => window.Clerk?.loaded)  // Never true on 404 page

// RIGHT — verifies the SPA is actually served:
const response = await page.goto('/')
expect(response?.status()).toBe(200)
await page.locator('#app').isVisible()  // Fails fast if 'Site Not Found' page
await page.waitForFunction(() => window.Clerk?.loaded)

Symptom: waitForFunction(Clerk.loaded) times out at 30 seconds with no visible error on the page (just a blank 'Site Not Found' response).

Applies to: Any Playwright smoke test against Firebase Hosting preview channels or production deployments that uses Clerk.loaded or similar async SPA state as a setup sentinel. See OBJ-657.

CLERK_PUBLISHABLE_KEY vs VITE_CLERK_PUBLISHABLE_KEY in CI

Problem: The build-time VITE_ prefix variable is not visible to Playwright runners. Both must be set for smoke runs to authenticate:

  • VITE_CLERK_PUBLISHABLE_KEY — passed to the Vite build (frontend bundle)
  • CLERK_PUBLISHABLE_KEY — passed to the Playwright runner's clerkSetup() at runtime

If only the VITE_ var is set, clerkSetup() will throw Error: You need to set the CLERK_PUBLISHABLE_KEY environment variable and the entire smoke suite will skip (not fail, skip — 186 tests will not run).

Applies to: Any Playwright smoke test setup using Clerk authentication where the runner environment and build environment are separate (e.g., GitHub Actions with separate build and test steps).


Load Testing (k6)

A fourth automated layer, distinct from the four above: monthly scheduled k6 load tests run against staging only, never production or PR CI. Five scenarios cover addGoalEvent, communityFeed + goals queries, syncUser, the AI Workforce webhook, and /health — each with a p95/error-rate threshold. It's a signal source, not a merge gate.

Full detail (scenarios, trigger, required secrets, where results land, failure response) lives in docs/operations/observability.md#periodic-load-testing-k6; script internals (thresholds, auth setup, hand-running instructions) are in load_tests/README.md.


Storybook

Deployed at storybook.objectuve.com. Auto-deploys via GitHub Actions when component or Storybook config files change on master.

Quick Reference

bash
cd ionic_frontend

# Start Storybook dev server (port 6006)
npm run storybook

# Build static Storybook
npm run build-storybook

Inventory

  • 337 story files across ionic_frontend/src/
  • Stories are co-located with their components as ComponentName.stories.ts
  • Tags: ['autodocs'] for automatic documentation generation

play() functions are CI-enforced

Every story's play() function now runs as a real Vitest test in headless Chromium, via the storybook-test job in ci.yml — it is a required check with no continue-on-error.

bash
cd ionic_frontend

# Full suite — every story file, plus the discovered-vs-reported completeness check
npm run test:storybook

# Scoped to one file — skips the completeness check by design (a scoped run is
# expected to discover fewer files than the full glob)
npm run test:storybook -- src/components/GoalCard.stories.ts

npm run test:storybook is a wrapper (ionic_frontend/scripts/check-storybook-file-count.mjs), not a direct vitest call — it runs pretest:storybook's two parity checks first (ionic_frontend/scripts/check-storybook-config-parity.mjs, ionic_frontend/scripts/check-storybook-mock-router-parity.mjs), then diffs every story file discovered on disk against the files Vitest actually reported a result for, and fails loudly on any gap. Never call npx vitest --config vitest.storybook.config.ts directly for a full run — that bypasses the completeness floor.

Coverage policy (decided by Orion, Phase 3 planning): render-only stories are legitimate smoke coverage, not a gap with a numeric target. Every story file is executed by the harness regardless of whether it defines a play(), so a render-only story still catches mount crashes, missing providers, and teardown races. The rule that follows is semantic, not numeric: a story that claims a state in its name or args — an outro, an error, an empty, a loading — must have a play() that asserts that state actually rendered. A story that only claims "this component mounts" does not need one. There is no coverage-percentage target for play() adoption.

Tier C anti-pattern: an empty or optional-chained play() (e.g. play: async () => {} or a body that only does await within(canvasElement)?.findByRole(...) with no assertion) reports green while asserting nothing. Now that storybook-test is a required gate, that pattern is a way to launder a broken component through CI — a play() must contain a real assertion, not just exercise the harness.

Shared fixtures live in .storybook/preview.ts. Its setup() registers the Clerk Vue plugin, IonicVue, Pinia, and the mock router's route table globally for every story — a missing plugin or an unregistered named route fails every story that reaches it, not just one (this is what Phase 2 Wave A's harness fixes closed: the Clerk plugin was never installed, and the mock router was missing a route a real component navigated to). Apollo is not registered globally here — components that need DefaultApolloClient opt in per story, either via the shared .storybook/decorators/withApollo.ts decorator or a direct provide(DefaultApolloClient, mockClient) call in the story file itself.

Configuration (.storybook/)

  • Framework: @storybook/vue3-vite
  • Addons: docs, a11y (accessibility), vitest integration, Chromatic (visual regression)
  • Decorators: IonicVue, Pinia, Vue Router with mock routes
  • Mocks: Session helper mocked for authentication-dependent components

Writing Stories

typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import MyComponent from './MyComponent.vue'

const meta: Meta<typeof MyComponent> = {
  title: 'Components/MyComponent',
  component: MyComponent,
  tags: ['autodocs'],
  argTypes: {
    variant: { control: 'select', options: ['primary', 'secondary'] },
  },
}

export default meta
type Story = StoryObj<typeof MyComponent>

export const Default: Story = {
  args: { variant: 'primary', label: 'Click me' },
}

Development Tools

GraphiQL (Interactive API Explorer)

A browser-based GraphQL IDE for manually testing queries and mutations against the local API. Available only in development.

URL: http://localhost:3000/graphiql

bash
cd rails_api
rails s  # Start the API server
# Open http://localhost:3000/graphiql in your browser

Authentication: The headers panel is pre-populated with the Fallback auth pattern. Replace the placeholder values with your local FALLBACK_AUTH_SECRET and a test user's email:

json
{
  "SessionToken": "Fallback your_secret_here:user@example.com"
}

For unauthenticated queries (e.g., goalKinds, goalCategories), leave the header empty or remove it.

How it works: The controller (app/controllers/graphiql_controller.rb) inherits from ActionController::Base (not the API-only ApplicationController) so it can render HTML. GraphiQL JS/CSS loads from unpkg CDN — no gem dependencies. The route is guarded by Rails.env.development? so it's never exposed in production or staging.


Mailer Previews

Rails built-in mailer preview system for visually inspecting email templates in the browser without sending real emails.

Index: http://localhost:3000/rails/mailers/

PreviewURLTemplate
Welcome/rails/mailers/user_mailer/welcomeEmail confirmation link
Magic Code/rails/mailers/user_mailer/magic_code6-character sign-in code
Reset Password/rails/mailers/user_mailer/reset_passwordPassword reset link
bash
cd rails_api
rails s  # Start the API server
# Open http://localhost:3000/rails/mailers/ in your browser

Each preview renders both the HTML and plain text versions. Toggle between them using the links at the top of the preview page.

Preview class: spec/mailers/previews/user_mailer_preview.rb. Uses the first user in the dev database, or falls back to a sample User.new(...) if the database is empty. Tokens (remember_token, password_reset_token) are set inline to ensure URLs render correctly.

Adding new previews: When adding a new mailer method, add a corresponding method in UserMailerPreview (or create a new preview class for a new mailer). The preview will appear automatically at /rails/mailers/.


CI/CD Integration

Workflows and What They Run

CI is a single unified workflow, ci.yml ("CI"), triggered on pull_request (to master, gsd/*, feat/*) and on push to master. Every required job runs unconditionally on every trigger; the one non-required job, cypress-e2e, is gated by detect-cypress-changes and skips on docs-only/non-Cypress-relevant PRs (see its row below) — deliberately not wired into any required check, so a skip never leaves a PR stuck waiting on a status that never reports:

JobWhat it runs
detect-cypress-changesAllow-list check (ionic_frontend/src/, ionic_frontend/tests/e2e/, Cypress/Vite config, design_system/, ci.yml itself, etc.) that sets app_changed, consumed only by cypress-e2e below. Not required.
lint-workflowsactionlint (workflow syntax/duplicate-key checks) + a guard against hardcoded Firebase preview URL hashes
rspecRSpec + Rubocop, sharded 3-way (bin/partition_specs) across matrix shard: [0, 1, 2]. Shard 0 also runs rake regression_guard:full_stack_routes after the RSpec step — a boot-time smoke check for full-stack HTML routes (GET/POST /privacy/request) that must run outside the RSpec process, since rspec-rails permanently monkeypatches ActionDispatch::Request with #flash at load time regardless of the app's real config.api_only middleware stack (OBJ-2100)
rspec-coverageMerges the 3 shard SimpleCov artifacts and asserts the 85% coverage floor (bin/assert_merged_coverage)
frontend-lintESLint, npm audit --audit-level=high --omit=dev, and the button-border + detached-wait guards for ionic_frontend (OBJ-1942: split out of vitest so the two halves run in parallel instead of serially). Is a required status check — its display-name context, "Frontend Lint + Guards (ionic_frontend)", is present in GitHub ruleset 17927598's required_status_checks (confirmed via gh api repos/objectuve-softworks/enkidu/rulesets/17927598 on 2026-09-07), so a failing lint/audit/guard run blocks merge.
vitestVitest unit tests (with coverage) for ionic_frontend. Ruleset 17927598 matches required checks on the job's display-name context (name:), not its job id. The name: is now "Vitest (ionic_frontend)" — the stale + ESLint suffix left over from the OBJ-1942 split was dropped by PR #2976. The ruleset was not updated in the same change, which orphaned the old required context and blocked every PR for ~2 days (OBJ-3613) until it was corrected by hand on 2026-09-10; a live read on 2026-09-11 confirms the new name. Renaming this job again requires updating the ruleset and REQUIRED_CONTEXTS in scripts/check-ruleset-required-contexts.mjs in the same change. (Supersedes OBJ-3485, which had declined the rename for exactly the reason OBJ-3613 then demonstrated.)
vitest-adminVitest unit tests for admin_dashboard and agent_runner
a11yjest-axe WCAG 2.1 AA specs (tests/unit/views/**/a11y.spec.ts)
cypress-e2eCypress E2E tier — all 6 specs matching cypress.config.ts's specPattern, no --spec filter (ally-flow, a11y-smoke, sign-in, create-goal-with-past-attempt, create-goal-with-identity, complete-goal-with-reflection); skipped for Dependabot PRs, and skipped when detect-cypress-changes reports no Cypress-relevant changes (e.g. a docs-only PR)
cross-app-helper-syncFails if ionic_frontend and admin_dashboard's crossAppUrls.ts copies have drifted
schema-driftRegenerates schema.graphql via rake graphql:schema:dump and fails on diff; also runs frontend/admin GraphQL codegen and fails on drift in both ionic_frontend/src/generated/graphql.ts and admin_dashboard/src/generated/graphql.ts — all three checks are hard gates, no continue-on-error (removed under OBJ-1734/OBJ-1750/OBJ-1736; see Common Gotchas)
posthog-flag-driftOnly on pull_request; runs when featureFlags.ts changed, checks PostHog flag registration drift

Separately, deploy-and-smoke workflows run outside ci.yml:

WorkflowTriggerWhat it does
preview.ymlPR opened/sync/reopen, or manual dispatchdetect-app-changes gates the rest of the workflow on an allow-list similar to (but broader than) detect-cypress-changes above; skips docs-only/non-app-touching PRs. When app-touching: deploy Firebase preview channel -> playwright-smoke.yml (advisory, workflow_dispatch-scoped)
staging.ymlPush to master, or manual dispatchBuild + deploy backend/litellm/frontend to staging
production.ymlPush of a v* tagValidate -> deploy backend/litellm/frontend to production -> playwright-smoke.yml -> GitHub Release
playwright-smoke.ymlCalled by the above (workflow_call) or manual workflow_dispatchPlaywright smoke suite against a deployed environment

Test Pipeline Flow

Push to feature branch / open PR to master:
  ci.yml -> detect-cypress-changes, lint-workflows, rspec (x3 shards) + rspec-coverage, frontend-lint, vitest,
            vitest-admin, a11y, cypress-e2e (skipped if detect-cypress-changes finds no Cypress-relevant
            changes), cross-app-helper-sync, schema-drift, posthog-flag-drift
  preview.yml -> detect-app-changes gates the rest (skipped for docs-only PRs) -> Deploy preview channel
                 (manual/PR-scoped) -> playwright-smoke.yml (advisory)

Merge to master:
  ci.yml      -> (same jobs as above, on push)
  staging.yml -> Deploy backend/litellm/frontend to staging

Push a version tag (v*):
  production.yml -> Validate tag is on master -> Deploy to production -> playwright-smoke.yml -> GitHub Release

Required GitHub Actions Secrets

All CI/CD workflows require GitHub Actions secrets configured in Repository Settings → Secrets and variables → Actions. If a required secret is missing, the workflow fails at runtime.

SecretPurposeWhere to AddNotes
SMOKE_CLERK_EMAILClerk test account email for smoke authRepo Settings → Secrets and variables → ActionsUsed in playwright-smoke.yml to authenticate test user. Must be a valid Clerk test account.
SMOKE_FALLBACK_EMAILFallback auth test emailRepo Settings → Secrets and variables → ActionsUsed in playwright-smoke.yml for fallback authentication (legacy).
ACHIEVEMENTS_EMPTY_EMAILTest account with no achievementsRepo Settings → Secrets and variables → ActionsOptional, matching ci-secrets-checklist.md's "Optional" framing. Used in smoke tests to validate achievement surfaces on fresh accounts; each consuming spec self-skips when absent (test.skip(!email, ...) in specs/gamification/11-achievements-empty.spec.ts:7, achievements.spec.ts:19) rather than failing.
ACHIEVEMENTS_POPULATED_EMAILTest account with achievementsRepo Settings → Secrets and variables → ActionsOptional, matching ci-secrets-checklist.md's "Optional" framing. Used in smoke tests to validate achievement rendering with data; each consuming spec self-skips when absent (test.skip(!email, ...) in specs/gamification/12-achievements-populated.spec.ts:7, achievements.spec.ts:31) rather than failing.
CLERK_SECRET_KEYClerk API secret keyRepo Settings → Secrets and variables → ActionsUsed in playwright-smoke.yml and production deployment for Clerk operations.
CLERK_SECRET_KEY_PRODProduction Clerk API secret keyRepo Settings → Secrets and variables → ActionsProduction-smoke-specific: used by playwright-smoke.yml in place of CLERK_SECRET_KEY only when smoke_env == 'production' (the run that gates the GitHub Release).
FALLBACK_AUTH_SECRETFallback authentication secretRepo Settings → Secrets and variables → ActionsUsed in playwright-smoke.yml and production deployment for legacy auth.
VITE_CLERK_PUBLISHABLE_KEY_PRODProduction Clerk publishable keyRepo Settings → Secrets and variables → ActionsProduction-smoke-specific: used by playwright-smoke.yml in place of VITE_CLERK_PUBLISHABLE_KEY only when smoke_env == 'production'.
GCP_SERVICE_ACCOUNTGCP service account emailRepo Settings → Secrets and variables → ActionsUsed in preview.yml and production.yml for Workload Identity.
GCP_WORKLOAD_IDENTITY_PROVIDERGCP Workload Identity Provider resource nameRepo Settings → Secrets and variables → ActionsUsed in preview.yml and production.yml for OIDC federation with GitHub Actions.
SENTRY_DSN_APISentry API error tracking DSNRepo Settings → Secrets and variables → ActionsUsed in production.yml for backend error tracking.
LITELLM_URL_PRODUCTIONLiteLLM proxy endpoint (production)Repo Settings → Secrets and variables → ActionsUsed in production.yml to route AI requests through LiteLLM.

Required GitHub Actions Variables

Unlike secrets, these are configured in Repository Settings → Secrets and variables → Actions → Variables and are not masked in logs.

VariablePurposeWhere to AddNotes
GCP_PROJECT_IDGoogle Cloud Platform project IDRepo Settings → Secrets and variables → Actions → VariablesRead as vars.GCP_PROJECT_ID in staging.yml, production.yml, and preview.yml for GCP authentication — a Variable, not a Secret.

Troubleshooting: If smoke tests or deployments fail with missing-secret errors, check:

  1. The secret name matches exactly (case-sensitive)
  2. The secret is added to the repository (not organization) level
  3. The secret has a non-empty value
  4. For GCP secrets, verify the Workload Identity federation is configured on the GCP side

Writing New Tests

When implementing a new feature, tests are expected at every applicable layer:

Checklist

  • [ ] Model specs — validations, associations, scopes, custom methods (spec/models/)
  • [ ] Interaction specs — success and failure paths, side effects (spec/interactions/)
  • [ ] GraphQL specs — mutation/query integration tests (spec/graphql/)
  • [ ] Job specs — if adding background jobs (spec/jobs/)
  • [ ] Frontend unit tests — components and composables (tests/unit/)
  • [ ] E2E tests — critical user flows with mocked API (tests/e2e/specs/)
  • [ ] Storybook stories — new or modified UI components (ComponentName.stories.ts)

Backend Test Template

ruby
# spec/interactions/do_something_spec.rb
require 'rails_helper'

RSpec.describe DoSomething do
  let(:user) { create(:user) }

  describe '#call' do
    context 'when inputs are valid' do
      it 'succeeds and returns expected details' do
        result = described_class.call(user: user, name: 'Test')
        expect(result).to be_success
        expect(result.details[:record].name).to eq('Test')
      end
    end

    context 'when inputs are invalid' do
      it 'fails with an error message' do
        result = described_class.call(user: user, name: '')
        expect(result).to be_failure
        expect(result.error).to include('Name')
      end
    end
  end
end

Frontend Test Template

typescript
// tests/unit/composables/useMyFeature.spec.ts
import { useMyFeature } from '@/composables/useMyFeature'

describe('useMyFeature', () => {
  it('initializes with default state', () => {
    const { value } = useMyFeature()
    expect(value.value).toBe(null)
  })

  it('updates state on action', () => {
    const { value, doAction } = useMyFeature()
    doAction('test')
    expect(value.value).toBe('test')
  })
})

Running Everything Before Pushing

bash
# Backend
cd rails_api
bundle exec rubocop && bundle exec rspec

# Frontend
cd ionic_frontend
npm run lint && npm run test:unit -- --run

Testing Gotchas

Mutation specs must use public_id, not user.id

When testing GraphQL mutations or interactions that accept user ID inputs, always use user.public_id (base64 token), never user.id (integer).

Why this matters: Specs that pass user.id or user.id.to_s will pass CI because the test database uses integer ID lookups (find_by(id: …)). However, production fails silently because the frontend always sends user.public_id. The mismatch goes undetected until production.

Wrong (fails in production):

ruby
# spec/graphql/mutations/update_showcased_achievements_spec.rb
result = RailsServerSchema.execute(
  mutation,
  variables: {
    userId: user.id.to_s,  # Integer converted to string — WRONG
    achievementKeys: ['badge1']
  },
  context: { current_user: user }
)

Right (matches frontend behavior):

ruby
result = RailsServerSchema.execute(
  mutation,
  variables: {
    userId: user.public_id,  # Base64 token — CORRECT
    achievementKeys: ['badge1']
  },
  context: { current_user: user }
)

Interaction lookups: Backend interactions should also use Model.public_find(id), not find_by(id: id), when resolving ID inputs from GraphQL:

ruby
# WRONG:
def user
  @user ||= User.find_by(id: user_id)
end

# RIGHT:
def user
  @user ||= User.public_find(user_id)
end

Apply this pattern to any input named *_id that crosses the GraphQL boundary.

Mocking frozen constants with stub_const

When a constant is frozen (e.g., a JSONB lookup hash), RSpec's allow(...).to receive(:fetch) pattern raises ArgumentError: Cannot proxy frozen objects. Use stub_const instead to replace the frozen object for the duration of the example:

Wrong (raises ArgumentError):

ruby
allow(Ai::Prompts::EnneagramFramings::FRAMINGS).to receive(:fetch) { 'some value' }

Right (replaces the constant):

ruby
stub_const('Ai::Prompts::EnneagramFramings::FRAMINGS', { assessment.dominant_type => 'framing text' }.freeze)

Usage: This pattern is essential when testing code that accesses frozen constants. The frozen hash is replaced for that example only; the original constant is restored after the test.

Interaction gem [ClassName Error] warn output is not a test failure

The interaction gem prints [ClassName Error] <message> and a backtrace to stderr whenever an exception is rescued. In CI logs, these messages appear before the RSpec summary and include the calling spec's file/line in the backtrace — making them look like test failures at those lines.

Example: A spec exercising an error path intentionally (e.g., "rejects an invalid life_area") will output:

[GoalTracking::AddGoal Error] Validation failed: Life area is not included in the list

.../add_goal_spec.rb:72:in `block (3 levels) in <top (required)>'

The test at line 72 is passing — it asserts expect(result).not_to be_a_successful_interaction. The warn output is a side effect of the gem's design, not a failure signal.

To confirm whether a spec is actually failing: check the Failures: section of the RSpec output, not the warn messages above it. If a spec file/line appears only in warn output but not in Failures:, the test is passing.

Vitest DOM-element count mismatches with multiple element categories

When a component renders multiple element categories sharing the same tag or role (e.g., a component that renders 9 type rows and 1 dominant gradient decoration, both with <div>), generic selectors like queryAllByRole('region') or wrapper.findAll('div') may return more elements than expected. This surfaces as:

AssertionError: expected 9 to equal 10

Why this happens: A component like EnneagramResultCard.vue renders 9 <div> type-score rows plus 1 <div> gradient background, totaling 10 elements. A test that counts queryAllByRole('generic') gets 10, not 9.

Fix: Use more-specific selectors that target only the category you're testing:

Wrong (counts all divs):

typescript
const typeRows = wrapper.findAll('div')
expect(typeRows).toHaveLength(9)  // Fails: finds 10 (includes gradient div)

Right (counts only type rows):

typescript
const typeRows = wrapper.findAll('[data-testid="type-row"]')
expect(typeRows).toHaveLength(9)  // Passes: finds only type rows

Another approach: If the component doesn't have test IDs, add them during implementation, or use a combination of class names and parent context:

typescript
// Inside EnneagramResultCard:
const typeRows = wrapper.find('[class*="type-score"]').findAll('div')
// Or:
const typeRows = wrapper.findAllComponents(EnneagramTypeRow)

Apply this pattern when: (1) a component renders heterogeneous elements sharing a tag, (2) your test assertion counts total elements, and (3) you only care about a subset.

Firebase Preview: HTTP 200 ≠ JS-ready

When running Playwright smoke tests against a Firebase Preview deployed channel, the HTTP 200 status response arrives before JavaScript bundles have finished evaluating and initializing.

The trap: The CI "Wait for Frontend" step polls the preview URL for HTTP 200, which returns quickly (~5 seconds). Your smoke test sees this and assumes the app is ready. In reality, the browser has the HTML but the JS execution (Vue bootstrap, Apollo initialization, Clerk JS load() call) is still in-flight.

Why it's subtle: On a warm Firebase Preview channel (one that has already served the URL before), JS bundles are cached and the race disappears — the test passes reliably. But on a fresh channel (first deploy to a new preview URL), every JS bundle is fetched from GCP storage (~40–60s), and then Clerk's async load() call adds another ~30–60s. Total setup time: 100–160s consistently. If your waitForFunction timeout is 30s or even 90s, it fails every time on a cold start.

Example failure pattern (OBJ-659):

✘ Error: page.waitForFunction: Test timeout of 90000ms exceeded.
  at auth.staging.setup.ts:59:14
  
Retry #1 — [Test timeout of 90000ms exceeded.]
1 failed

Both the initial attempt and Playwright's own retry both hit the 90s budget, confirming the cold-start floor is consistently above 90s.

Fix: Scope Playwright smoke tests to workflow_dispatch (manual trigger) only, not on every PR push. When run manually after a channel has been warm for a few minutes, JS bundles are cached and Clerk initialization completes within a reasonable timeout. Do not run smoke tests on PR-push cold-starts — the timeouts will race deterministically and waste 13+ minutes of CI per PR.

Configuration (.github/workflows/preview.yml):

yaml
# ❌ Don't do this — fires on every PR push, fails 100% of the time on cold-start
jobs:
  playwright-smoke:
    if: github.event_name == 'pull_request'
    uses: ./.github/workflows/playwright-smoke.yml

# ✅ Do this — keep Playwright smoke scoped to workflow_dispatch only
# in a separate .github/workflows/playwright-smoke.yml file:
on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options: [staging, production]

Manual trigger: Use GitHub Actions UI: Actions → "Playwright Smoke" → Run workflow → Select staging or production.

Applies to: Any Playwright smoke test setup that uses a fresh user account + async Clerk initialization against a deployed preview environment.


Dashboard queries use errorPolicy: 'all' — error UI gates on absence of data

Both queries backing the Dashboard full-screen view — USER_QUERY (Dashboard.vue:691) and GOALS_QUERY (Dashboard.vue:740) — use errorPolicy: 'all'. The hasError computed (Dashboard.vue:761) gates the error UI on the absence of cached data, not on the error alone:

typescript
const hasError = computed(() =>
  !!(userError.value || goalsError.value) && !goalsResult.value?.goals && !userResult.value?.user
)

This means a transient or partial error never unmounts content already in the Apollo cache — the view renders normally alongside the error.

Why this matters for smoke tests: Playwright specs in the dashboard suite that assert on greeting text, goals list, or streak pill depend on this resilience. A query that drops errorPolicy: 'all' or tightens the hasError gate to fire even when cached data is present will cause those specs to time out on transient API hiccups.

Rule: Apply the same errorPolicy: 'all' + data-absence gate pattern to any new query that backs a full-screen view. See Dashboard.vue:691–761 for the reference implementation.


Component stubs prevent rendering but not module loading — add vi.mock() for heavy transitive deps

Applies to: Vitest specs for any component whose template includes a child that (a) has heavy transitive imports (@/apollo-client, Capacitor plugins, large third-party libraries) and (b) is already in the config.global.stubs map.

The trap. config.global.stubs (or per-mount stubs: { MyComponent: true }) tells Vue Test Utils to render a dummy element in place of a component. It does not prevent Vite from transforming and loading that component's source file and its entire import chain. When the parent is first imported dynamically (await import('@/views/Dashboard.vue')), Vite walks every static import — including imports inside stubbed child components — and transforms them all.

If a newly-added stubbed child imports @/apollo-client (which pulls in ActionCable, Sentry, Apollo, and actioncable), the first test that mounts the parent can exceed the 5 000 ms default Vitest timeout while the chain transforms. Tests 1 and 2 share the pending import() promise and both fail; tests 3+ see the now-cached module and complete in milliseconds.

Why it's subtle. Stubs work perfectly for rendering isolation, so tests pass in normal development. The slowdown only surfaces when a newly-added stubbed child introduces a heavy import that wasn't previously in the module graph — and only when the parent import is dynamic rather than static (static imports are resolved eagerly before tests run and miss the timeout window).

Concrete case (OBJ-1014). PR #1193 added InviteShareSheet.vue to StreamlinedDashboard.vue (a child of Dashboard.vue). InviteShareSheet.vue imports @/apollo-client directly and @capacitor/share. Both were already in the stubs map, so rendering was suppressed — but Dashboard.render.spec.ts dynamically mounts Dashboard and the first await import('@/views/Dashboard.vue') had to transform the full InviteShareSheet.vue chain on cold cache. Tests 1 and 2 consistently timed out at 5 000 ms; tests 3–15 completed in <50 ms once the module was cached.

Before — stubs suppress rendering but the module graph still loads:

typescript
// tests/unit/setup.ts (global stubs)
config.global.stubs = {
  InviteShareSheet: true,  // prevents rendering — does NOT prevent transform
  StreamlinedDashboard: true,
  // ... 80+ other Ionic/app components
}

// Dashboard.render.spec.ts — no vi.mock() for InviteShareSheet's imports
// First mountDashboard() call triggers Vite to transform InviteShareSheet.vue,
// @/apollo-client, @capacitor/share, @/constants/graphql/social, and all their
// transitive deps. Cold-cache transform time: >5 s. Tests 1–2 timeout.

After — vi.mock() at the module level short-circuits the transform entirely:

typescript
// Dashboard.render.spec.ts

// 1. Short-circuit the SFC itself: Vite never loads its source or walks its imports.
//    The stub in config.global.stubs still handles rendering; this handles the transform.
vi.mock('@/components/allies/InviteShareSheet.vue', () => ({
  default: { name: 'InviteShareSheet', template: '<div />' },
}))
vi.mock('@/components/dashboard/streamlined/StreamlinedDashboard.vue', () => ({
  default: { name: 'StreamlinedDashboard', template: '<div />' },
}))

// 2. Mock the heavy direct imports (defence-in-depth; also needed for any other
//    component in the graph that imports them without going through InviteShareSheet):
vi.mock('@/apollo-client', () => ({
  apolloClient: { query: vi.fn(), mutate: vi.fn() },
  apolloProvider: {},
}))
vi.mock('@/constants/graphql/social', () => ({
  CREATE_ALLY_INVITE_MUTATION: 'CREATE_ALLY_INVITE_MUTATION',
  REVOKE_ALLY_INVITE_MUTATION: 'REVOKE_ALLY_INVITE_MUTATION',
  MY_ALLY_INVITES_QUERY: 'MY_ALLY_INVITES_QUERY',
}))

// 3. Raise the file-level timeout so the first cold-cache dynamic import of
//    Dashboard.vue completes on slower CI runners:
vi.setConfig({ testTimeout: 20_000 })

All 15 tests now run in <200 ms after the first-run cache warm-up.

Rule. When you add a component to a parent's template and the new child (a) has heavy transitive imports and (b) is already a no-render stub, also add a vi.mock() for the child's .vue file. This short-circuits Vite's transform for the child's entire sub-graph. Mock individual heavy modules too (@/apollo-client, Capacitor plugins) when they're shared across multiple stubs.

Global mock manifest. Modules that are heavy AND never tested directly can go in tests/unit/setup.ts so every spec gets the lightweight stub without declaring it:

typescript
// tests/unit/setup.ts — already present after OBJ-1014:
vi.mock('@capacitor/share', () => ({
  Share: { canShare: vi.fn().mockResolvedValue({ value: false }), share: vi.fn().mockResolvedValue({}) },
}))

@/apollo-client is intentionally not in the global manifest: tests/unit/apollo-client.test.ts directly tests the real module (with its transitive deps mocked) and would break if a global stub intercepted the import. Mock @/apollo-client per-spec. If a future contributor wants to add it globally, they must also add vi.unmock('@/apollo-client') to apollo-client.test.ts so that test restores the real module.

@capacitor/share mocks are now vestigial, not dead-in-the-sense-of-removable. v4.56 Phase 3 (CARD-03) migrated InviteShareSheet.vue off @capacitor/share onto the raw Web Share API (src/share/webShare.ts) — no src/ file imports the package anymore. The three mocks this section documents (tests/unit/setup.ts:156, Dashboard.render.spec.ts:113, Dashboard.spec.ts:28) now guard against a transitive import that no longer exists on InviteShareSheet.vue's chain; they're harmless (an unused vi.mock() is a no-op) but no longer load-bearing for this rule's example. @capacitor/share itself is still an installed dependency (package.json, both native platform configs) — removing it is a separate native-platform-config task, not a docs or test change. See Celebration Sharing Cards § Testing & tooling debt.

Apply this pattern when: (1) you add a new component to a parent's template, (2) the new component is already a no-render stub in config.global.stubs, and (3) the new component has any direct import from @/apollo-client, a Capacitor plugin, or another large library.


A cancel-in-progress concurrency group only stops overlap, not out-of-order completion

Applies to: any GitHub Actions job with a bare, job-name-scoped concurrency group (e.g. staging-deploy-litellm) and cancel-in-progress: true, where only the result of the newest commit should ever take effect (a deploy, a publish step, anything with a durable side effect).

The trap. cancel-in-progress: true cancels a run only if it is genuinely still executing when a newer run for the same group starts. It does nothing once a run has already passed the point where it could be cancelled and is simply racing an in-flight sibling run to the finish line. GitHub Actions gives no guarantee that two concurrent runs' jobs complete in commit order — whichever job physically finishes last wins, even if it's building an older commit. This surfaced in production as 00-version-tag.spec.ts (Playwright smoke) failing with a real, reproducible git-describe mismatch — a correct symptom of an older commit's deploy silently overwriting a newer one, not a flake.

Fix. Add a guard step immediately before the job's actual side-effecting action that re-checks the branch tip (git ls-remote origin refs/heads/<branch>) against github.sha, and skip the side-effecting step (via a job output like is_stale) if a newer commit has since landed. Any downstream job that depends on this one's result (e.g. a smoke-test gate) should also skip when the upstream job reports itself stale, so it doesn't spuriously fail validating output that was never actually published.

Full write-up, the concrete incident (OBJ-1144), and the exact guard-step code are in Staging deploy race: unscoped concurrency lets an older commit overwrite a newer one in docs/operations/deployment.md.


Describe-level let/before setup leaks into a nested use_transactional_tests = false context

Applies to: any RSpec file where a describe-level let + before { create(...) } sits above a nested context that flips self.use_transactional_tests = false to exercise something transactional fixtures would mask (e.g. real multi-connection concurrency).

The trap. Nested contexts inherit every describe-level let and before hook — including ones an outer scope never intended to expose to a non-transactional example. If the inherited before hook references an outer let, FactoryBot creates that record for the non-transactional example too, outside the transaction Rails would normally roll back. A context's own after/around cleanup that only tracks its own explicitly-created records never touches these inherited rows, so they leak past the example and persist in the real database — capable of corrupting any spec that runs later in the same process and does an unscoped query (e.g. Model.find_each).

Concrete case (OBJ-1163, PR #1280). spec/jobs/teams/team_leaderboard_upsert_job_spec.rb declares describe-level let(:team) / let(:user) and before { FactoryBot.create(:team_membership, team:, user:) }. The nested context 'concurrent enqueues...' sets self.use_transactional_tests = false to exercise real multi-connection concurrency, but the inherited before hook still ran for its example, creating a real Team/User/TeamMembership outside transactional fixtures. The context's own cleanup only ever tracked its own concurrent_* records, so the inherited rows survived every run of the file and could corrupt specs that ran later in the same shard.

Fix. Scope DatabaseCleaner truncation to every table the inherited setup can touch, not just the tables the context references directly:

ruby
context 'concurrent enqueues (real threads, no transactional fixtures)' do
  self.use_transactional_tests = false

  around do |example|
    DatabaseCleaner.strategy = :truncation, { only: %w[teams users team_memberships team_leaderboard_entries],
                                              truncate_option: :cascade }
    DatabaseCleaner.cleaning { example.run }
  end

  let!(:concurrent_team) { FactoryBot.create(:team) }
  # ...
end

:only must enumerate every table the describe-level let/before setup can create rows in — not just the tables this context's own factories touch — otherwise the inherited rows still leak. See commit bf2216cdb (PR #1280) for the reference fix.

Apply this pattern when: a nested context sets self.use_transactional_tests = false inside a spec file that has any describe-level let/before above it. Either move the non-transactional context to its own file (nothing to inherit), or add DatabaseCleaner truncation scoped to every table the inherited setup touches.


A real (non-fake) debounce setTimeout outlives its test unless the component clears it on unmount

Applies to: any Vitest component spec that mounts a component using a real setTimeout for debounce/interval behavior (not vi.useFakeTimers()).

The trap. Unmounting a wrapper (wrapper.unmount(), or the suite-wide enableAutoUnmount(afterEach) safety net in tests/unit/setup.ts) only cancels a timer if the component itself registers that timer for teardown via onUnmounted. If the component never does, the real setTimeout keeps running on Node's event loop after the test — and after vi.clearAllMocks() in the next test's beforeEach — and can fire mid-way through an unrelated, later test. Because the mocks it calls into are often module-level/hoisted and shared across the whole spec file, the leaked timer silently pollutes a .not.toHaveBeenCalled() assertion in whatever test happens to be running when it fires. The result is nondeterministic CI failures where a different test fails each run, with no connection to that test's own code.

Concrete case (OBJ-1833, root-caused on OBJ-1818). ProfileCompletionModal.vue scheduled usernameTimeout = setTimeout(checkUsername, 500) on every username-input debounce but had no onUnmounted cleanup — the only clearTimeout was inside its own resetUsernameCheck. ProfileCompletionModal.render.spec.ts triggered the timer via usernameInput.trigger('input'), then called checkUsername() directly to bypass the debounce, without ever cancelling the scheduled timer. enableAutoUnmount(afterEach) alone would not have fixed this — unmounting can't cancel a timer the component never registered for teardown.

Fix — both sides, together:

  1. The component must clear its own timer in onUnmounted:

    typescript
    // ProfileCompletionModal.vue
    import { onUnmounted } from 'vue'
    
    let usernameTimeout: ReturnType<typeof setTimeout>
    
    onUnmounted(() => {
      clearTimeout(usernameTimeout)
    })
  2. Prefer vi.useFakeTimers() over bypassing the debounce manually — it also lets you assert the debounce delay itself instead of skipping past it:

    typescript
    // ProfileCompletionModal.render.spec.ts
    beforeEach(() => {
      vi.useFakeTimers()
    })
    
    afterEach(() => {
      vi.runOnlyPendingTimers()
      vi.clearAllTimers()
      vi.useRealTimers()
    })
    
    it('does not fire checkUsername after unmount, even past the debounce window (regression guard)', async () => {
      const wrapper = factory({ user: { firstName: 'Alice', lastName: 'Smith', username: null } })
      await wrapper.find('#profile-username').setValue('newuser')
      await wrapper.find('#profile-username').trigger('input')
    
      wrapper.unmount()
      await vi.advanceTimersByTimeAsync(500)
    
      expect(mockCheckUsernameMutate).not.toHaveBeenCalled()
    })
  3. tests/unit/setup.ts also gained a suite-wide enableAutoUnmount(afterEach) (from @vue/test-utils) as a safety net — but per the trap above, it only helps components that already clean up their own timers. It is not a substitute for step 1.

Rule. When a spec mounts a component with a real (non-fake) debounce/interval timer: (a) make sure the component clears that timer in onUnmounted, and (b) prefer vi.useFakeTimers() + explicit vi.advanceTimersByTimeAsync(...) over manually bypassing the debounce, with vi.clearAllTimers() / vi.useRealTimers() in afterEach. A regression-guard test — unmount, then advance past the debounce window, then assert the mock was never called — proves the leak is actually severed rather than passing on timing luck.

Apply this pattern when: you add or touch a component that schedules a real setTimeout/setInterval for debounce, throttle, or delayed-dismiss behavior and it's exercised by a Vitest component spec.


A remediation without an assertion is not remediated: it is only currently correct

Applies to: any fix that lands as a specific class (or attribute) on a specific element, rather than a change to a shared token, constant, or shared component.

The rule. When a fix is "this element needs this class," neither the review that approved it nor a guard on the token that class consumes protects it from being silently reverted later. A token guard proves the token's contract holds; it says nothing about which elements are still spending that token correctly. Guarded surfaces survive a bad merge or refactor; unguarded ones don't — and the difference is the presence of an assertion, not how careful the review was.

The evidence. v4.29 Phase 2 remediated four consumer-layer contrast defects (SuggestedGoalCard.vue, Dashboard.vue, Goal.vue, GoalCreate.vue) on the reasoning that the token contract those classes consume — themeContrast.spec.ts, tokenDrift.spec.ts — already guarded them. Phase 4's reconciliation merge (de08508ec) proved that wrong on its own milestone: Goal.vue:79's cover-photo scrim was silently reverted to its pre-fix classes while an unrelated focus-ring cleanup in the same file was correctly kept. It reached master and stayed there until a human caught it days later; it was re-applied by hand as OBJ-2086 (e6a4671ef, PR #2041). In the same merge, tokenDrift.spec.ts caught two other conflict-free-but-semantically-wrong resolutions automatically, with nobody watching — a stale --primary left in landing-2026.css/colors.ts, and an invalid lavender ink exception in customTheme.test.ts. The hand-written survival checklist for that merge tested file presence only; all nine files were present, so it passed. A reverted line inside a present file is invisible to a presence test. See App-Side AA-Violation Ledger for the full incident writeup.

The pattern. A source-text Vitest — read the file as text, anchor to the specific element (never assert against the whole file), and assert the class tokens both positively and negatively:

  • Positive: the fix's class is present on that element.
  • Negative: the specific pre-fix class is absent — a revert usually swaps classes rather than deleting them, so a positive-only assertion is weaker than it looks.
  • Word-boundary, not whole-string, matching on the class list, so an unrelated class reorder or addition doesn't fail the guard — only removal of the load-bearing class should.
  • No component import, no mount, no network — the same reasoning mainBootOrder.test.ts uses for main.ts: the file has side effects real enough that importing it for a test is worse than reading it as text.

The exemplars. ionic_frontend/tests/unit/mainBootOrder.test.ts is the precedent this pattern generalizes from (a boot-order invariant, not a class invariant, but the same "read as text, assert on string content, never import" shape). ionic_frontend/tests/unit/consumerContrastGuard.spec.ts (OBJ-2087/OBJ-2241) is the direct application — one guard per v4.29 consumer-layer fix, anchored to its element, asserting the fix class present and the pre-fix class absent.

The verification bar. A new guard must be observed failing against a tree where the fix is absent before it's trusted — the same bar themeContrast.spec.ts's FG-GUARD-1 guard was held to (demonstrated failing on the pre-fix tree, 31 declared / 17 failing, before being trusted). A guard that has never been seen to fail proves nothing; it might be asserting something that was never false to begin with.

Apply this pattern when: a fix changes a specific class or attribute on a specific element — a token-level guard on what that class consumes is necessary but not sufficient.


Last updated: 2026-09-14 (OBJ-3769: reconciled the "Required GitHub Actions Secrets" table's ACHIEVEMENTS_* rows to ci-secrets-checklist.md's "Optional" framing)

Prior update: 2026-09-14 (OBJ-3746: reconciled the "Gotcha: Required secrets" ACHIEVEMENTS_ framing to ci-secrets-checklist.md's "Optional" framing)*

Loading…