From a1cb4f450d03835cfe5293a48a6f41753eeb4694 Mon Sep 17 00:00:00 2001 From: MP2EZ <182439403+MP2EZ@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:46:34 -0700 Subject: [PATCH] chore: INFRA-217 seed post-onboarding state for the e2e-sim safety gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Maestro safety-e2e gate runs against the no-dev-client e2e-sim Release build (INFRA-216). That build boots/transitions slowly and the 16-question LegalGate + onboarding preamble flaked at 0-1/5. Seed post-onboarding state at launch so CleanRootNavigator routes straight to Main and the sim flows start at the surface they test. - env.ts: register EXPO_PUBLIC_E2E_SEED_ONBOARDED (booleanString.default('false'), no production superRefine — the e2e-sim profile resolves ENV=production, so a guard would refuse to boot the gate's own build). - e2eSeed.ts: maybeSeedE2EOnboardedState() — no-op unless the flag is 'true'; seeds onboarding-complete + legal consents + age verification (>=18) + full consent record via the real store APIs. Does NOT weaken canPerformOperation. Exposes whenE2ESeedComplete(): a module-level "seed gate" promise resolved when the seed lands (15s safety timeout), resolved immediately in real builds. - App.tsx: call maybeSeedE2EOnboardedState() after EncryptionService.initialize(). CleanRootNavigator mounts unconditionally (as in every real build). - CleanRootNavigator: checkInitialRoute awaits whenE2ESeedComplete() before reading state, so the FIRST resolved route is already Main (initialRouteName only applies on first navigator mount; a later flip would not navigate). - eas.json: set EXPO_PUBLIC_E2E_SEED_ONBOARDED=true ONLY in build.e2e-sim.env. - Maestro: new _seeded-home.yaml (wait for home-screen); repoint the 4 sim flows (q9/phq9/gad7/crisis-button) to it. crisis-988-dial.yaml + _legal-and-onboarding.yaml left untouched for the non-seeded device flow. - Tests (test-first): eas.json profile-scoping compliance pin, seed-gate unit tests (both branches + non-blocking), env schema default cases. Validated on the e2e-sim build: q9/phq9/gad7/crisis-button each route straight to home and pass. Compliance boundary reviewed by the compliance agent; the seed is impossible in any shipping build (var absent from all non-e2e profiles, defaults false), pinned in CI by e2eSeedGate.config.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/.maestro/_seeded-home.yaml | 25 +++ app/.maestro/crisis-button-reachability.yaml | 2 +- app/.maestro/gad7-severe.yaml | 2 +- app/.maestro/phq9-severe-completion.yaml | 2 +- app/.maestro/q9-single-alert.yaml | 2 +- app/App.tsx | 13 ++ .../safety/e2eSeedGate.config.test.ts | 74 +++++++++ app/__tests__/unit/e2e-seed-onboarded.test.ts | 145 ++++++++++++++++++ app/eas.json | 3 + app/src/core/config/e2eSeed.ts | 141 +++++++++++++++++ app/src/core/config/env.test.ts | 19 +++ app/src/core/config/env.ts | 16 ++ .../core/navigation/CleanRootNavigator.tsx | 7 + docs/testing/e2e-maestro.md | 46 ++++-- 14 files changed, 479 insertions(+), 18 deletions(-) create mode 100644 app/.maestro/_seeded-home.yaml create mode 100644 app/__tests__/safety/e2eSeedGate.config.test.ts create mode 100644 app/__tests__/unit/e2e-seed-onboarded.test.ts create mode 100644 app/src/core/config/e2eSeed.ts diff --git a/app/.maestro/_seeded-home.yaml b/app/.maestro/_seeded-home.yaml new file mode 100644 index 00000000..1bf2d830 --- /dev/null +++ b/app/.maestro/_seeded-home.yaml @@ -0,0 +1,25 @@ +appId: com.being.app +tags: + - helper +name: "Helper: wait for seeded Main tab (e2e-sim)" +--- +# Reusable subflow for the no-dev-client e2e-sim build (INFRA-217). Called via +# `- runFlow: _seeded-home.yaml` from each sim safety flow after a fresh +# launchApp { clearState: true, clearKeychain: true }. +# +# The e2e-sim EAS profile sets EXPO_PUBLIC_E2E_SEED_ONBOARDED=true, so App.tsx +# seeds legal consents + age verification + onboarding-complete at launch and +# CleanRootNavigator routes straight to Main. There is therefore NO LegalGate / +# onboarding preamble to traverse — we just wait for the home screen. +# +# Replaces the long `_legal-and-onboarding.yaml` traversal for the sim flows. +# That file is intentionally retained, unchanged, for the device-only +# `crisis-988-dial.yaml` flow, which runs on a non-seeded real-device build and +# still needs the full LegalGate + onboarding walk. +# +# The 90s timeout absorbs cold boot + encryption init + the async seed writes +# (SecureStore consent + AsyncStorage onboarding flag) on the slower Release build. +- extendedWaitUntil: + visible: + id: "home-screen" + timeout: 90000 diff --git a/app/.maestro/crisis-button-reachability.yaml b/app/.maestro/crisis-button-reachability.yaml index 60a2af57..d093e62d 100644 --- a/app/.maestro/crisis-button-reachability.yaml +++ b/app/.maestro/crisis-button-reachability.yaml @@ -16,7 +16,7 @@ name: "Crisis button reaches CrisisResources from every tab" - launchApp: clearState: true clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179) -- runFlow: _legal-and-onboarding.yaml +- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home # Tabs are addressed via tabBarTestID (INFRA-183, CleanTabNavigator.tsx) — # Maestro's text: selector doesn't match the bottom-tab accessibilityText diff --git a/app/.maestro/gad7-severe.yaml b/app/.maestro/gad7-severe.yaml index 99cdcd25..f6f44af5 100644 --- a/app/.maestro/gad7-severe.yaml +++ b/app/.maestro/gad7-severe.yaml @@ -12,7 +12,7 @@ name: "GAD-7 score >=15 shows crisis results" - launchApp: clearState: true clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179) -- runFlow: _legal-and-onboarding.yaml +- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home # INFRA-186: scrollUntilVisible required before take-gad7-button — # the card sits further below the fold than take-phq9-button. See diff --git a/app/.maestro/phq9-severe-completion.yaml b/app/.maestro/phq9-severe-completion.yaml index d20d21a6..e4cb8e5a 100644 --- a/app/.maestro/phq9-severe-completion.yaml +++ b/app/.maestro/phq9-severe-completion.yaml @@ -13,7 +13,7 @@ name: "PHQ-9 score >=20 completion shows crisis results" - launchApp: clearState: true clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179) -- runFlow: _legal-and-onboarding.yaml +- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home # INFRA-186: scrollUntilVisible required before take-phq9-button — # see q9-single-alert.yaml for the geometry rationale (card overflows the diff --git a/app/.maestro/q9-single-alert.yaml b/app/.maestro/q9-single-alert.yaml index 37661e26..2ff0bd2f 100644 --- a/app/.maestro/q9-single-alert.yaml +++ b/app/.maestro/q9-single-alert.yaml @@ -17,7 +17,7 @@ name: "PHQ-9 Q9 single canonical alert" - launchApp: clearState: true clearKeychain: true # SecureStore-backed consent persists across clearState — wipe keychain too (INFRA-179) -- runFlow: _legal-and-onboarding.yaml +- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home # Navigate to Profile → PHQ-9 entry point. INFRA-186: the PHQ-9 card sits # below the fold on iPhone 16 Plus (its bounds extend past screen height), diff --git a/app/App.tsx b/app/App.tsx index 8c7fb5b5..45a20c53 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -18,6 +18,7 @@ import { initializeCrisisMonitoring } from './src/core/services/monitoring'; import { DataRetentionService } from './src/core/services/data-retention'; import { PostHogProvider } from './src/core/analytics'; import { closeMenu as closeDevMenu } from 'expo-dev-menu'; +import { maybeSeedE2EOnboardedState } from './src/core/config/e2eSeed'; // INFRA-181: hide RN LogBox during Maestro runs. The dev warning toast (e.g. // posthog-react-native's "usePostHog was called without a client" notice when @@ -58,6 +59,18 @@ export default function App() { ); logSystem('Encryption service initialized'); + // INFRA-217: seed post-onboarding state for the e2e-sim safety gate. + // No-op unless EXPO_PUBLIC_E2E_SEED_ONBOARDED==='true' (e2e-sim profile + // only). Runs after EncryptionService.initialize() because the seeded + // consent record persists to SecureStore. Self-contained try/catch, so + // it never blocks init; gate the navigator render on its completion. + // INFRA-217: seed post-onboarding state for the e2e-sim safety gate. + // No-op unless EXPO_PUBLIC_E2E_SEED_ONBOARDED==='true' (e2e-sim profile + // only). Runs after EncryptionService.initialize() because the seeded + // consent record persists to SecureStore. Releases the seed gate that + // CleanRootNavigator awaits before resolving its initial route. + await maybeSeedE2EOnboardedState(); + // Remaining init tasks are independent. allSettled (not all) so one // best-effort failure doesn't abort the others. IAP init only runs // when the platform supports it. diff --git a/app/__tests__/safety/e2eSeedGate.config.test.ts b/app/__tests__/safety/e2eSeedGate.config.test.ts new file mode 100644 index 00000000..4499450d --- /dev/null +++ b/app/__tests__/safety/e2eSeedGate.config.test.ts @@ -0,0 +1,74 @@ +/** + * e2e-sim seed-gate config pin (INFRA-217) + * + * The Maestro safety gate runs against a no-dev-client EAS Release build whose + * `e2e-sim` profile seeds post-onboarding state at launch (consent + onboarding + * complete) so the safety flows start at the Main tab. That seed is gated by + * `EXPO_PUBLIC_E2E_SEED_ONBOARDED`, which MUST be set ONLY in the `e2e-sim` + * profile — never in a shipping build. + * + * Compliance boundary (INFRA-217 AC, `compliance` agent review): a production / + * preview / production-emergency build must be structurally incapable of + * auto-granting consent. There is deliberately NO `EXPO_PUBLIC_ENV==='production'` + * superRefine in env.ts (the `e2e-sim` profile `extends: production` and resolves + * `EXPO_PUBLIC_ENV=production`, so such a guard would refuse to boot the very + * build the gate needs). The boundary therefore rests SOLELY on this eas.json + * profile scoping — this static-config test is the durable, in-CI pin for it. + * Modeled on `lsApplicationQueriesSchemes.config.test.ts` (INFRA-184). + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const SEED_VAR = 'EXPO_PUBLIC_E2E_SEED_ONBOARDED'; + +const easJson = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', '..', 'eas.json'), 'utf8'), +) as { build: Record }> }; + +describe('EXPO_PUBLIC_E2E_SEED_ONBOARDED is scoped to the e2e-sim profile only', () => { + it('is set to "true" in build.e2e-sim.env', () => { + expect(easJson.build['e2e-sim']?.env?.[SEED_VAR]).toBe('true'); + }); + + // Every profile that can produce a shippable / non-e2e artifact must NOT carry + // the seed var. production-emergency is non-negotiable — it extends production + // and ships to the App Store. + it.each(['production', 'production-emergency', 'preview', 'development'])( + 'is absent from build.%s.env (no auto-grant in a non-e2e build)', + (profile) => { + const env = easJson.build[profile]?.env ?? {}; + expect(Object.prototype.hasOwnProperty.call(env, SEED_VAR)).toBe(false); + }, + ); + + it('appears in exactly one build profile across all of eas.json', () => { + const profilesWithVar = Object.entries(easJson.build) + .filter(([, cfg]) => cfg.env && Object.prototype.hasOwnProperty.call(cfg.env, SEED_VAR)) + .map(([name]) => name); + expect(profilesWithVar).toEqual(['e2e-sim']); + }); +}); + +describe('env.ts defaults the seed var to disabled', () => { + // Source-level assertion: the schema must declare the seed var with a 'false' + // default so any build that does not explicitly set it (i.e. every real build) + // resolves to disabled. Reading the source keeps this pin independent of the + // module-load env validation. + const envSource = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'core', 'config', 'env.ts'), + 'utf8', + ); + + it("registers EXPO_PUBLIC_E2E_SEED_ONBOARDED with booleanString.default('false')", () => { + expect(envSource).toMatch( + /EXPO_PUBLIC_E2E_SEED_ONBOARDED:\s*booleanString\.default\('false'\)/, + ); + }); + + it('reads the seed var explicitly in readRawEnv (preserves Babel inlining)', () => { + expect(envSource).toMatch( + /EXPO_PUBLIC_E2E_SEED_ONBOARDED:\s*process\.env\['EXPO_PUBLIC_E2E_SEED_ONBOARDED'\]/, + ); + }); +}); diff --git a/app/__tests__/unit/e2e-seed-onboarded.test.ts b/app/__tests__/unit/e2e-seed-onboarded.test.ts new file mode 100644 index 00000000..3a619aa0 --- /dev/null +++ b/app/__tests__/unit/e2e-seed-onboarded.test.ts @@ -0,0 +1,145 @@ +/** + * Unit tests for the e2e-sim onboarding seed gate (INFRA-217). + * + * `maybeSeedE2EOnboardedState()` writes a real post-onboarding state (onboarding + * flag + legal-gate consents + age verification + full consent record) at launch + * so the Maestro safety flows start at the Main tab instead of traversing the + * 16-question onboarding preamble on the slow e2e-sim Release build. + * + * Compliance boundary (INFRA-217 AC, `compliance` agent review): + * - The seed is a strict no-op unless `env.EXPO_PUBLIC_E2E_SEED_ONBOARDED === 'true'` + * — that var is set ONLY in the e2e-sim EAS profile. These tests pin both branches. + * - The seed uses the REAL store APIs (grantConsent / verifyAge / recordLegalGateConsents); + * it does NOT weaken `canPerformOperation(...)`. The eas.json profile scoping is pinned + * separately in `__tests__/safety/e2eSeedGate.config.test.ts`. + */ + +const SEED_MODULE = '@/core/config/e2eSeed'; + +interface SeedMocks { + loadSettings: jest.Mock; + markOnboardingComplete: jest.Mock; + grantConsent: jest.Mock; + verifyAge: jest.Mock; + recordLegalGateConsents: jest.Mock; + logSystem: jest.Mock; + logError: jest.Mock; +} + +/** + * Re-require the seed module with the env flag set to `flag` and all store / + * logging dependencies mocked. Returns the loaded module plus the mock fns so a + * test can assert which store APIs were (or were not) called. + */ +function loadSeed(flag: string | undefined): { run: () => Promise; mocks: SeedMocks } { + jest.resetModules(); + + const mocks: SeedMocks = { + loadSettings: jest.fn().mockResolvedValue(null), + markOnboardingComplete: jest.fn().mockResolvedValue(undefined), + grantConsent: jest.fn().mockResolvedValue(undefined), + verifyAge: jest.fn().mockResolvedValue({ eligible: true, age: 36 }), + recordLegalGateConsents: jest.fn().mockResolvedValue(undefined), + logSystem: jest.fn(), + logError: jest.fn(), + }; + + jest.doMock('@/core/config/env', () => ({ + env: { EXPO_PUBLIC_E2E_SEED_ONBOARDED: flag }, + })); + jest.doMock('@/core/stores/settingsStore', () => ({ + useSettingsStore: { + getState: () => ({ + loadSettings: mocks.loadSettings, + markOnboardingComplete: mocks.markOnboardingComplete, + }), + }, + })); + jest.doMock('@/core/stores/consentStore', () => ({ + useConsentStore: { + getState: () => ({ verifyAge: mocks.verifyAge, grantConsent: mocks.grantConsent }), + }, + recordLegalGateConsents: mocks.recordLegalGateConsents, + })); + jest.doMock('@/core/services/logging', () => ({ + logSystem: mocks.logSystem, + logError: mocks.logError, + LogCategory: { SYSTEM: 'system' }, + })); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(SEED_MODULE) as { maybeSeedE2EOnboardedState: () => Promise }; + return { run: mod.maybeSeedE2EOnboardedState, mocks }; +} + +describe('maybeSeedE2EOnboardedState — gate (INFRA-217)', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + it('is a no-op when the flag is unset (real builds)', async () => { + const { run, mocks } = loadSeed(undefined); + await run(); + expect(mocks.markOnboardingComplete).not.toHaveBeenCalled(); + expect(mocks.grantConsent).not.toHaveBeenCalled(); + expect(mocks.recordLegalGateConsents).not.toHaveBeenCalled(); + expect(mocks.verifyAge).not.toHaveBeenCalled(); + }); + + it("is a no-op when the flag is the string 'false'", async () => { + const { run, mocks } = loadSeed('false'); + await run(); + expect(mocks.markOnboardingComplete).not.toHaveBeenCalled(); + expect(mocks.grantConsent).not.toHaveBeenCalled(); + }); + + it("does not treat a non-'true' truthy value ('1') as enabled", async () => { + const { run, mocks } = loadSeed('1'); + await run(); + expect(mocks.markOnboardingComplete).not.toHaveBeenCalled(); + expect(mocks.grantConsent).not.toHaveBeenCalled(); + }); + + describe("when the flag is exactly 'true' (e2e-sim profile)", () => { + it('marks onboarding complete (routing checks this first)', async () => { + const { run, mocks } = loadSeed('true'); + await run(); + expect(mocks.markOnboardingComplete).toHaveBeenCalledTimes(1); + }); + + it('records legal-gate consents with mental-health processing consent', async () => { + const { run, mocks } = loadSeed('true'); + await run(); + expect(mocks.recordLegalGateConsents).toHaveBeenCalledWith( + expect.objectContaining({ + tosAccepted: true, + privacyAccepted: true, + wellnessDisclaimerAcknowledged: true, + mentalHealthProcessingConsent: true, + }), + ); + }); + + it('grants a full consent record with an eligible (18+) age verification', async () => { + const { run, mocks } = loadSeed('true'); + await run(); + expect(mocks.verifyAge).toHaveBeenCalledTimes(1); + expect(mocks.grantConsent).toHaveBeenCalledTimes(1); + + const [prefs, ageVerification] = mocks.grantConsent.mock.calls[0]; + // mentalHealthProcessingConsent unlocks the assessment / check-in screens + // the safety flows exercise (GDPR Art. 9(2)(a)). + expect(prefs.mentalHealthProcessingConsent).toBe(true); + expect(ageVerification.verified).toBe(true); + expect(ageVerification.isEligible).toBe(true); + }); + + it('is non-blocking: a store failure is swallowed, not thrown', async () => { + const { run, mocks } = loadSeed('true'); + mocks.grantConsent.mockRejectedValueOnce(new Error('SecureStore unavailable')); + await expect(run()).resolves.toBeUndefined(); + expect(mocks.logError).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/eas.json b/app/eas.json index 0df15472..302c6cfe 100644 --- a/app/eas.json +++ b/app/eas.json @@ -74,6 +74,9 @@ "ios": { "simulator": true, "buildConfiguration": "Release" + }, + "env": { + "EXPO_PUBLIC_E2E_SEED_ONBOARDED": "true" } }, "production-emergency": { diff --git a/app/src/core/config/e2eSeed.ts b/app/src/core/config/e2eSeed.ts new file mode 100644 index 00000000..cce5bda1 --- /dev/null +++ b/app/src/core/config/e2eSeed.ts @@ -0,0 +1,141 @@ +/** + * E2E onboarding state seed (INFRA-217). + * + * The Maestro safety-e2e gate runs against a no-dev-client EAS Release build + * (`e2e-sim` profile, INFRA-216). That build boots/transitions slowly, and the + * 16-question LegalGate + onboarding preamble in `_legal-and-onboarding.yaml` is + * timing-fragile on it (~20 points, 0–1/5 consecutive passes). Rather than tune + * an unbounded set of flow timeouts, we seed a real post-onboarding state at + * launch so `CleanRootNavigator` routes straight to the Main tab, and the sim + * safety flows start at the surface they actually test (`_seeded-home.yaml`). + * + * Gate: this is a strict no-op unless `EXPO_PUBLIC_E2E_SEED_ONBOARDED === 'true'`, + * which is set ONLY in the `e2e-sim` EAS profile (eas.json build.e2e-sim.env). + * Every real build resolves the var to its 'false' default, so the seed branch + * never runs in production / preview / production-emergency. The compliance + * boundary (no consent auto-grant in a shipping build) rests on that profile + * scoping and is pinned by `__tests__/safety/e2eSeedGate.config.test.ts`. + * + * The seed writes REAL state via the same store APIs a user's LegalGate flow + * uses — it does NOT weaken or bypass `useConsentStore.canPerformOperation(...)`. + * Must run after `EncryptionService.initialize()` (consent persists to + * SecureStore, which depends on the encryption keys). + */ + +import { env } from './env'; +import { useSettingsStore } from '../stores/settingsStore'; +import { + useConsentStore, + recordLegalGateConsents, + type ConsentPreferences, + type AgeVerification, +} from '../stores/consentStore'; +import { logSystem, logError, LogCategory } from '../services/logging'; + +/** + * Deterministic eligible birth year for the seeded age verification. Any year + * giving an age ≥ 18 satisfies the `isEligible` gate that makes consentStatus + * resolve to 'valid'; 1990 is comfortably clear of the 18+ boundary. + */ +const SEED_BIRTH_YEAR = 1990; + +/** Whether the e2e-sim onboarding seed is enabled for this build. */ +export const isE2EOnboardingSeedEnabled = (): boolean => + env.EXPO_PUBLIC_E2E_SEED_ONBOARDED === 'true'; + +const SEED_ACTIVE = isE2EOnboardingSeedEnabled(); + +// Module-level "seed gate" promise. CleanRootNavigator awaits it BEFORE reading +// persisted state, so its FIRST route resolution sees the seeded onboarding + +// consent (rather than racing the seed and resolving to LegalGate — which would +// stick, since `initialRouteName` only applies on first navigator mount). +// +// Why a gate promise instead of conditionally mounting the navigator: a +// `null → ` conditional mount silently failed to commit in +// the SDK-56 Release build (the navigator function was never invoked). Mounting +// it unconditionally — exactly as a real build does — and deferring only the +// *route decision* is the robust pattern. +// +// Resolved by `maybeSeedE2EOnboardedState()` (after EncryptionService.initialize), +// so it is created pending at module load and resolved once the seed lands. +let resolveSeedGate: () => void = () => {}; +const seedGate: Promise = SEED_ACTIVE + ? new Promise((resolve) => { + resolveSeedGate = resolve; + }) + : Promise.resolve(); + +/** + * Awaited by CleanRootNavigator before reading persisted state. Resolves + * immediately in every real build (seed disabled). In the e2e-sim build it + * resolves when the seed completes, with a safety timeout so the navigator can + * never hang on its LoadingScreen if the seed never runs. + */ +export function whenE2ESeedComplete(): Promise { + if (!SEED_ACTIVE) return Promise.resolve(); + return Promise.race([ + seedGate, + new Promise((resolve) => setTimeout(resolve, 15000)), + ]); +} + +/** + * Seed legal consents + age verification + onboarding-complete so the app boots + * straight to Main. No-op unless the e2e-sim seed flag is set. Non-blocking: any + * failure is logged and swallowed, and the seed gate is always released so a + * flaky seed never black-screens the build. + */ +export async function maybeSeedE2EOnboardedState(): Promise { + if (!SEED_ACTIVE) return; + + try { + logSystem('[E2ESeed] Seeding post-onboarding state for e2e-sim build (INFRA-217)'); + + // 1. Onboarding flag — CleanRootNavigator checks `onboardingCompleted` FIRST, + // before consent. loadSettings() creates defaults if none exist yet + // (launchApp { clearState } wipes them every run). + const settings = useSettingsStore.getState(); + await settings.loadSettings(); + await settings.markOnboardingComplete(); + + // 2. Legal-gate consents — mirrors what CombinedLegalGateScreen records. + await recordLegalGateConsents({ + tosAccepted: true, + privacyAccepted: true, + wellnessDisclaimerAcknowledged: true, + mentalHealthProcessingConsent: true, + }); + + // 3. Age verification (≥18) + full consent record via the real store API. + // mentalHealthProcessingConsent unlocks the assessment / check-in screens + // the safety flows exercise (GDPR Art. 9(2)(a) explicit consent). + const { verifyAge, grantConsent } = useConsentStore.getState(); + const { age, eligible } = await verifyAge(SEED_BIRTH_YEAR); + const ageVerification: AgeVerification = { + verified: true, + birthYear: SEED_BIRTH_YEAR, + ageAtVerification: age, + verifiedAt: Date.now(), + isEligible: eligible, + }; + const preferences: ConsentPreferences = { + analyticsEnabled: true, + crashReportsEnabled: true, + cloudSyncEnabled: true, + researchEnabled: true, + mentalHealthProcessingConsent: true, + }; + await grantConsent(preferences, ageVerification); + + logSystem('[E2ESeed] Post-onboarding state seeded; navigator will route to Main'); + } catch (error) { + logError( + LogCategory.SYSTEM, + '[E2ESeed] Failed to seed post-onboarding state (non-blocking)', + error as Error, + ); + } finally { + // Always release the navigator's route-decision gate, even on failure. + resolveSeedGate(); + } +} diff --git a/app/src/core/config/env.test.ts b/app/src/core/config/env.test.ts index 31b5a81f..73b4931f 100644 --- a/app/src/core/config/env.test.ts +++ b/app/src/core/config/env.test.ts @@ -218,6 +218,25 @@ describe('env schema (INFRA-141, clinical safety)', () => { }); }); + describe('INFRA-217: e2e-sim onboarding-seed flag', () => { + it('defaults EXPO_PUBLIC_E2E_SEED_ONBOARDED to "false" when absent (real builds)', () => { + const parsed = envSchema.parse(validEnv); + expect(parsed.EXPO_PUBLIC_E2E_SEED_ONBOARDED).toBe('false'); + }); + it('accepts an explicit "true" (e2e-sim profile) even under ENV=production', () => { + // The e2e-sim profile extends production, so it resolves ENV=production. + // The seed flag must parse there — there is deliberately no production + // superRefine guarding it (see env.ts / e2eSeedGate.config.test.ts). + const result = envSchema.safeParse({ ...validEnv, EXPO_PUBLIC_E2E_SEED_ONBOARDED: 'true' }); + expect(result.success).toBe(true); + }); + it('rejects a non-boolean value', () => { + expect( + envSchema.safeParse({ ...validEnv, EXPO_PUBLIC_E2E_SEED_ONBOARDED: '1' }).success, + ).toBe(false); + }); + }); + describe('error messages do not leak received values', () => { it('SUICIDE_PREVENTION_URL rejection names the var and constraint, not the bad value', () => { const badValue = 'https://attacker.example.com/credentials?token=secret'; diff --git a/app/src/core/config/env.ts b/app/src/core/config/env.ts index 7eccfda6..31530335 100644 --- a/app/src/core/config/env.ts +++ b/app/src/core/config/env.ts @@ -174,6 +174,21 @@ export const envSchema = z // Read by certificate-pinning.ts. Absent from .env files (default = false). // Schema refuses boot if set truthy in production env (see superRefine). EXPO_PUBLIC_ALLOW_INSECURE_SSL: booleanString.default('false'), + + // === E2E onboarding seed (INFRA-217) === + // When 'true', App.tsx seeds post-onboarding state at launch (consent + + // onboarding-complete) so the Maestro safety flows start at the Main tab + // instead of traversing the 16-question onboarding preamble on the slow + // no-dev-client e2e-sim Release build. Set ONLY in the `e2e-sim` EAS profile + // (eas.json build.e2e-sim.env); absent → default 'false' in every real build. + // + // Deliberately NO `EXPO_PUBLIC_ENV==='production'` superRefine (unlike + // ALLOW_INSECURE_SSL above): the e2e-sim profile `extends: production` and + // resolves EXPO_PUBLIC_ENV=production, so such a guard would refuse to boot + // the very build the safety gate needs. The compliance boundary instead rests + // solely on eas.json profile scoping, pinned by the static-config test at + // `__tests__/safety/e2eSeedGate.config.test.ts`. + EXPO_PUBLIC_E2E_SEED_ONBOARDED: booleanString.default('false'), }) .superRefine((env, ctx) => { // Insecure SSL must not be enabled in production builds. @@ -263,6 +278,7 @@ function readRawEnv(): Record { EXPO_PUBLIC_PERFORMANCE_BREATHING_FPS_MIN: process.env['EXPO_PUBLIC_PERFORMANCE_BREATHING_FPS_MIN'], EXPO_PUBLIC_PERFORMANCE_CHECKIN_TRANSITION_MAX_MS: process.env['EXPO_PUBLIC_PERFORMANCE_CHECKIN_TRANSITION_MAX_MS'], EXPO_PUBLIC_ALLOW_INSECURE_SSL: process.env['EXPO_PUBLIC_ALLOW_INSECURE_SSL'], + EXPO_PUBLIC_E2E_SEED_ONBOARDED: process.env['EXPO_PUBLIC_E2E_SEED_ONBOARDED'], }; } diff --git a/app/src/core/navigation/CleanRootNavigator.tsx b/app/src/core/navigation/CleanRootNavigator.tsx index 05e40dff..d7cb0f9c 100644 --- a/app/src/core/navigation/CleanRootNavigator.tsx +++ b/app/src/core/navigation/CleanRootNavigator.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect } from 'react'; import { View, ActivityIndicator, StyleSheet, TouchableOpacity, Text } from 'react-native'; import { logPerformance, logSystem } from '@/core/services/logging'; +import { whenE2ESeedComplete } from '@/core/config/e2eSeed'; import { generateTimestampedId } from '@/core/utils/id'; import { NavigationContainer } from '@react-navigation/native'; import { linkingConfig } from './linking'; @@ -113,6 +114,12 @@ const CleanRootNavigator: React.FC = () => { useEffect(() => { async function checkInitialRoute() { + // INFRA-217: in the e2e-sim build, wait for the launch-time seed to write + // onboarding + consent before reading state, so the FIRST resolved route is + // already Main (initialRouteName only applies on first navigator mount). + // Resolves immediately in every real build. + await whenE2ESeedComplete(); + // Both reads are independent AsyncStorage gets — parallelize. const [settings, consent] = await Promise.all([loadSettings(), loadConsent()]); diff --git a/docs/testing/e2e-maestro.md b/docs/testing/e2e-maestro.md index 32d4c11b..3758a6d9 100644 --- a/docs/testing/e2e-maestro.md +++ b/docs/testing/e2e-maestro.md @@ -77,17 +77,28 @@ npm run e2e:safety:build # EAS local build (e2e-sim profile) + install on the - First build is ~10–15 min (EAS local). After that the sim can stay open across many flow runs. -> 🟡 **Known limitation (INFRA-216 follow-up).** The no-dev-client build removes -> the dev launcher — the dominant flake — but the no-dev-client **Release build -> boots/transitions noticeably slower** than a dev build, and the long -> LegalGate + 16-question onboarding preamble in `_legal-and-onboarding.yaml` is -> still timing-fragile at several points on it (cold-boot, the DOB picker, the -> Welcome→assessment modal). Single warm runs pass clean end-to-end, but -> consecutive ≥5/5 is not yet there. The robust fix — seeding post-onboarding -> state behind an **`e2e-sim`-profile-only env var** (so the flows start at the -> home screen and skip the fragile preamble entirely; gated to that build profile, -> never production, with a compliance review) — is tracked as the INFRA-216 -> follow-up. Until then, expect occasional retries on the preamble. +> ✅ **Resolved (INFRA-217): the sim flows skip the preamble via a seeded state.** +> The no-dev-client Release build boots/transitions slowly, and the long LegalGate +> + 16-question onboarding preamble in `_legal-and-onboarding.yaml` was too +> timing-fragile for consecutive ≥5/5. The robust fix shipped: the `e2e-sim` EAS +> profile sets **`EXPO_PUBLIC_E2E_SEED_ONBOARDED=true`** (eas.json +> `build.e2e-sim.env`), which makes `App.tsx` seed post-onboarding state at launch +> — legal consents + age verification (≥18) + onboarding-complete, written via the +> real store APIs (`grantConsent` / `verifyAge` / `recordLegalGateConsents`). With +> that state present, `CleanRootNavigator` routes straight to Main, so the four sim +> flows (`q9`, `phq9`, `gad7`, `crisis-button`) now `runFlow: _seeded-home.yaml` +> (just `extendedWaitUntil home-screen`) instead of traversing the preamble. +> +> **Compliance boundary.** The seed is impossible in any shipping build: the env +> var lives ONLY in the `e2e-sim` profile (absent from production / preview / +> production-emergency / development) and defaults to `'false'` in `env.ts`. There +> is deliberately no `EXPO_PUBLIC_ENV==='production'` guard — the `e2e-sim` profile +> `extends: production` and resolves `EXPO_PUBLIC_ENV=production`, so such a guard +> would refuse to boot the gate's own build. The boundary is pinned in CI by +> `app/__tests__/safety/e2eSeedGate.config.test.ts`. The seed does NOT weaken the +> canonical `useConsentStore.canPerformOperation(...)` gate. The device-only +> `crisis-988-dial.yaml` runs on a non-seeded real-device build and still uses the +> full `_legal-and-onboarding.yaml` traversal (left unchanged). ### Dev-mode caveats (INFRA-171 / INFRA-216 verification findings) @@ -140,11 +151,18 @@ npm run e2e:safety:988-dial # 988 button does not show "Unable to Call" f Each flow under `app/.maestro/`: 1. Starts with `appId: com.being.app` and `tags: [safety]`. -2. Calls `- launchApp: { clearState: true }` for a fresh install. -3. Runs `- runFlow: _legal-and-onboarding.yaml` to traverse the LegalGate (age picker + 4 consent toggles) and the 5-screen Onboarding flow before reaching the main tab navigator. This traversal is shared via the underscore-prefixed helper subflow. +2. Calls `- launchApp: { clearState: true, clearKeychain: true }` for a fresh install. +3. Reaches the main tab navigator. The path differs by build: + - **Sim flows** (`q9`, `phq9`, `gad7`, `crisis-button`) run on the seeded `e2e-sim` + build and call `- runFlow: _seeded-home.yaml` — a one-line helper that just waits + for the `home-screen` testID. The app self-seeds onboarding state at launch + (INFRA-217), so there is no LegalGate / onboarding to traverse. + - **Device-only** `crisis-988-dial.yaml` runs on a non-seeded real-device build and + still calls `- runFlow: _legal-and-onboarding.yaml` to traverse the LegalGate (age + picker + 4 consent toggles) and the 5-screen Onboarding flow. 4. Drives the safety surface (taps testIDs, asserts visible/notVisible). -The traversal subflow uses text-based selectors for legal-gate consent text (more robust than testIDs for legal copy that may rotate). It uses `optional: true` for onboarding intermediate Next/Continue taps so minor copy changes don't break flows — if a button isn't found, Maestro skips that step and continues. +The `_legal-and-onboarding.yaml` traversal subflow uses text-based selectors for legal-gate consent text (more robust than testIDs for legal copy that may rotate). It uses `optional: true` for onboarding intermediate Next/Continue taps so minor copy changes don't break flows — if a button isn't found, Maestro skips that step and continues. ## Anatomy of one flow