From c96deb52f9be7c87fe7fb11ad099e15d64c90fee Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 31 May 2026 20:08:56 -0600 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(expo):=20add?= =?UTF-8?q?=20lib=20wrappers=20+=20lint=20for=20platform-sensitive=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add apps/expo/lib/ wrapper modules so platform-sensitive native/expo dependencies are imported through a single seam, with .web.ts variants where web behavior diverges: - secureStore (+ .web localStorage shim — expo-secure-store throws on web) - googleSignin (+ .web stub — native-only module) - expoSqliteKvStore (+ .web localStorage shim — native SQLite has no web build) - asyncStorage (single re-export — works on web via localStorage) Add scripts/lint/no-direct-wrapped-imports.ts with a WRAPPED map that fails when any wrapped module is imported directly outside its wrapper, and wire it into lint:custom. --- apps/expo/lib/asyncStorage.ts | 5 + apps/expo/lib/expoSqliteKvStore.ts | 5 + apps/expo/lib/expoSqliteKvStore.web.ts | 61 ++++++++ apps/expo/lib/googleSignin.ts | 9 ++ apps/expo/lib/googleSignin.web.ts | 34 +++++ apps/expo/lib/secureStore.ts | 16 +++ apps/expo/lib/secureStore.web.ts | 48 +++++++ package.json | 2 +- scripts/lint/no-direct-wrapped-imports.ts | 162 ++++++++++++++++++++++ 9 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 apps/expo/lib/asyncStorage.ts create mode 100644 apps/expo/lib/expoSqliteKvStore.ts create mode 100644 apps/expo/lib/expoSqliteKvStore.web.ts create mode 100644 apps/expo/lib/googleSignin.ts create mode 100644 apps/expo/lib/googleSignin.web.ts create mode 100644 apps/expo/lib/secureStore.ts create mode 100644 apps/expo/lib/secureStore.web.ts create mode 100644 scripts/lint/no-direct-wrapped-imports.ts diff --git a/apps/expo/lib/asyncStorage.ts b/apps/expo/lib/asyncStorage.ts new file mode 100644 index 0000000000..e01d3622e7 --- /dev/null +++ b/apps/expo/lib/asyncStorage.ts @@ -0,0 +1,5 @@ +// @react-native-async-storage/async-storage ships a localStorage-backed web +// implementation out of the box, so a single re-export works on every +// platform. This wrapper exists so call sites never import the native module +// path directly (per the lib/ wrapper convention). +export { default } from '@react-native-async-storage/async-storage'; diff --git a/apps/expo/lib/expoSqliteKvStore.ts b/apps/expo/lib/expoSqliteKvStore.ts new file mode 100644 index 0000000000..01fd07038c --- /dev/null +++ b/apps/expo/lib/expoSqliteKvStore.ts @@ -0,0 +1,5 @@ +// Default export is the `Storage` singleton from expo-sqlite/kv-store, an +// AsyncStorage-compatible store with extra synchronous methods (getItemSync, +// setItemSync). The native module has no working web build, so .web.ts backs +// the same surface with localStorage. +export { default } from 'expo-sqlite/kv-store'; diff --git a/apps/expo/lib/expoSqliteKvStore.web.ts b/apps/expo/lib/expoSqliteKvStore.web.ts new file mode 100644 index 0000000000..648dac54ed --- /dev/null +++ b/apps/expo/lib/expoSqliteKvStore.web.ts @@ -0,0 +1,61 @@ +// expo-sqlite/kv-store relies on the native SQLite module, which has no usable +// web build. Back the same API (async aliases + sync variants + getAllKeys) +// with localStorage so callers need no Platform branches on web. + +const getItemSync = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +const setItemSync = (key: string, value: string): void => { + localStorage.setItem(key, value); +}; + +const removeItemSync = (key: string): void => { + localStorage.removeItem(key); +}; + +const getAllKeysSync = (): string[] => { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key !== null) keys.push(key); + } + return keys; +}; + +const Storage = { + getItem: (key: string): Promise => Promise.resolve(getItemSync(key)), + setItem: (key: string, value: string): Promise => { + setItemSync(key, value); + return Promise.resolve(); + }, + removeItem: (key: string): Promise => { + removeItemSync(key); + return Promise.resolve(); + }, + getAllKeys: (): Promise => Promise.resolve(getAllKeysSync()), + clear: (): Promise => { + localStorage.clear(); + return Promise.resolve(); + }, + getItemAsync: (key: string): Promise => Promise.resolve(getItemSync(key)), + setItemAsync: (key: string, value: string): Promise => { + setItemSync(key, value); + return Promise.resolve(); + }, + removeItemAsync: (key: string): Promise => { + removeItemSync(key); + return Promise.resolve(true); + }, + getAllKeysAsync: (): Promise => Promise.resolve(getAllKeysSync()), + getItemSync, + setItemSync, + removeItemSync, + getAllKeysSync, +}; + +export default Storage; diff --git a/apps/expo/lib/googleSignin.ts b/apps/expo/lib/googleSignin.ts new file mode 100644 index 0000000000..f43454403c --- /dev/null +++ b/apps/expo/lib/googleSignin.ts @@ -0,0 +1,9 @@ +export { + GoogleSignin, + GoogleSigninButton, + isCancelledResponse, + isErrorWithCode, + isNoSavedCredentialFoundResponse, + isSuccessResponse, + statusCodes, +} from '@react-native-google-signin/google-signin'; diff --git a/apps/expo/lib/googleSignin.web.ts b/apps/expo/lib/googleSignin.web.ts new file mode 100644 index 0000000000..f397c32eb4 --- /dev/null +++ b/apps/expo/lib/googleSignin.web.ts @@ -0,0 +1,34 @@ +// @react-native-google-signin/google-signin is a native module with no usable +// web implementation. Native Google Sign-In is not wired up for web (web auth +// uses a different OAuth flow), so this stub keeps the bundle building and +// fails loudly only if a web caller actually tries to trigger native sign-in. + +const notAvailable = (): never => { + throw new Error('Native Google Sign-In is not available on web.'); +}; + +export const GoogleSignin = { + configure: () => {}, + hasPlayServices: () => Promise.resolve(false), + signIn: notAvailable, + signInSilently: notAvailable, + getTokens: notAvailable, + hasPreviousSignIn: () => false, + signOut: () => Promise.resolve(null), + revokeAccess: () => Promise.resolve(null), + getCurrentUser: () => null, +}; + +export const statusCodes = { + SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED', + IN_PROGRESS: 'IN_PROGRESS', + PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE', + SIGN_IN_REQUIRED: 'SIGN_IN_REQUIRED', +} as const; + +export const isErrorWithCode = (_error: unknown): boolean => false; +export const isSuccessResponse = (_response: unknown): boolean => false; +export const isCancelledResponse = (_response: unknown): boolean => false; +export const isNoSavedCredentialFoundResponse = (_response: unknown): boolean => false; + +export const GoogleSigninButton = (): null => null; diff --git a/apps/expo/lib/secureStore.ts b/apps/expo/lib/secureStore.ts new file mode 100644 index 0000000000..5bd05fd01f --- /dev/null +++ b/apps/expo/lib/secureStore.ts @@ -0,0 +1,16 @@ +export { + AFTER_FIRST_UNLOCK, + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + ALWAYS, + ALWAYS_THIS_DEVICE_ONLY, + canUseBiometricAuthentication, + deleteItemAsync, + getItem, + getItemAsync, + isAvailableAsync, + setItem, + setItemAsync, + WHEN_PASSCODE_SET_THIS_DEVICE_ONLY, + WHEN_UNLOCKED, + WHEN_UNLOCKED_THIS_DEVICE_ONLY, +} from 'expo-secure-store'; diff --git a/apps/expo/lib/secureStore.web.ts b/apps/expo/lib/secureStore.web.ts new file mode 100644 index 0000000000..2393a43dc6 --- /dev/null +++ b/apps/expo/lib/secureStore.web.ts @@ -0,0 +1,48 @@ +// expo-secure-store's web build is an empty stub that throws when called, +// which silently breaks web auth. Back it with localStorage instead. +// +// SECURITY NOTE: localStorage is NOT encrypted. This is acceptable for the +// web MVP because the browser already stores auth state in JS-accessible +// memory; the native builds keep using the real Keychain/Keystore. + +const KEY_PREFIX = 'securestore:'; + +function storageKey(key: string): string { + return `${KEY_PREFIX}${key}`; +} + +export const getItem = (key: string): string | null => { + try { + return localStorage.getItem(storageKey(key)); + } catch { + return null; + } +}; + +export const setItem = (key: string, value: string): void => { + localStorage.setItem(storageKey(key), value); +}; + +export const getItemAsync = (key: string): Promise => Promise.resolve(getItem(key)); + +export const setItemAsync = (key: string, value: string): Promise => { + setItem(key, value); + return Promise.resolve(); +}; + +export const deleteItemAsync = (key: string): Promise => { + localStorage.removeItem(storageKey(key)); + return Promise.resolve(); +}; + +export const isAvailableAsync = (): Promise => Promise.resolve(true); + +export const canUseBiometricAuthentication = (): boolean => false; + +export const AFTER_FIRST_UNLOCK = 1; +export const AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY = 2; +export const ALWAYS = 3; +export const ALWAYS_THIS_DEVICE_ONLY = 4; +export const WHEN_PASSCODE_SET_THIS_DEVICE_ONLY = 5; +export const WHEN_UNLOCKED = 6; +export const WHEN_UNLOCKED_THIS_DEVICE_ONLY = 7; diff --git a/package.json b/package.json index c13042028a..c3c48d3940 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", - "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run scripts/lint/no-owned-max-params.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts && bun run scripts/lint/no-unauth-routes.ts && bun run scripts/lint/check-drizzle-migrations.ts", + "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run scripts/lint/no-owned-max-params.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts && bun run scripts/lint/no-unauth-routes.ts && bun run scripts/lint/check-drizzle-migrations.ts && bun run scripts/lint/no-direct-wrapped-imports.ts", "lint:strict": "biome check && bun run lint:custom", "lint:weak-assertions": "bun run scripts/lint/no-weak-assertions.ts", "lint-unsafe": "biome check --write --unsafe", diff --git a/scripts/lint/no-direct-wrapped-imports.ts b/scripts/lint/no-direct-wrapped-imports.ts new file mode 100644 index 0000000000..f45b43bd09 --- /dev/null +++ b/scripts/lint/no-direct-wrapped-imports.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env bun +// +// no-direct-wrapped-imports.ts — enforces the apps/expo/lib/ wrapper convention. +// +// Platform-sensitive external dependencies (native modules, expo-* packages +// that are stubbed or throw on web, etc.) must be imported through a wrapper +// module in apps/expo/lib/ rather than directly. The wrapper's `.web.ts` +// variant handles browser behavior so callers need no `Platform.OS` branches +// and the web bundle (`expo export -p web`) keeps working. +// +// Each entry in WRAPPED maps a module specifier to the wrapper that should be +// imported instead. A direct import of a wrapped module anywhere in apps/expo +// (outside the wrapper itself and test/spec files) is a violation. +// +// Exit code: +// 0 — no violations +// 1 — violations found (details printed to stdout) +// +// Wired into the `lint:custom` script in root package.json. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dir, '..', '..'); +const EXPO_ROOT = join(ROOT, 'apps', 'expo'); + +// module specifier (or specifier prefix) → wrapper import path the call site +// should use instead. The `wrapperFile` is the wrapper's source path relative +// to apps/expo, used to exempt the wrapper itself from the rule. +interface WrappedEntry { + /** Import specifier that is forbidden outside the wrapper. */ + module: string; + /** Wrapper path callers should import from instead. */ + wrapper: string; + /** Wrapper source files (relative to apps/expo) exempt from the rule. */ + wrapperFiles: string[]; +} + +const WRAPPED: WrappedEntry[] = [ + { + module: 'expo-secure-store', + wrapper: 'expo-app/lib/secureStore', + wrapperFiles: ['lib/secureStore.ts', 'lib/secureStore.web.ts'], + }, + { + module: 'expo-apple-authentication', + wrapper: 'expo-app/lib/appleAuthentication', + wrapperFiles: ['lib/appleAuthentication.ts', 'lib/appleAuthentication.web.ts'], + }, + { + module: 'expo-updates', + wrapper: 'expo-app/lib/updates', + wrapperFiles: ['lib/updates.ts', 'lib/updates.web.ts'], + }, + { + module: '@react-native-async-storage/async-storage', + wrapper: 'expo-app/lib/asyncStorage', + wrapperFiles: ['lib/asyncStorage.ts'], + }, + { + module: '@react-native-google-signin/google-signin', + wrapper: 'expo-app/lib/googleSignin', + wrapperFiles: ['lib/googleSignin.ts', 'lib/googleSignin.web.ts'], + }, + { + module: 'expo-sqlite/kv-store', + wrapper: 'expo-app/lib/expoSqliteKvStore', + wrapperFiles: ['lib/expoSqliteKvStore.ts', 'lib/expoSqliteKvStore.web.ts'], + }, +]; + +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '.expo', '.wrangler']); + +// app.config.ts / app.json reference plugin names as build-time strings, not +// runtime imports — they are not call sites and are exempt. +const EXEMPT_FILES = new Set(['app.config.ts', 'app.config.js', 'metro.config.js']); + +function isTargetFile(name: string): boolean { + return /\.(ts|tsx|cts|mts)$/.test(name) && !/\.(test|spec)\.(ts|tsx|cts|mts)$/.test(name); +} + +function buildImportPattern(module: string): RegExp { + // Matches `from 'module'` and `from 'module/subpath'` in both import and + // export-from statements, plus bare `import 'module'` side-effect imports. + const escaped = module.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?:from|import)\\s+['"]${escaped}(?:/[^'"]*)?['"]`); +} + +interface Violation { + file: string; + line: number; + content: string; + wrapper: string; +} + +const wrapperFileSet = new Set(WRAPPED.flatMap((e) => e.wrapperFiles)); +const patterns = WRAPPED.map((e) => ({ ...e, pattern: buildImportPattern(e.module) })); + +function walkDir(dir: string, relPath: string, violations: Violation[]): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry)) continue; + + const entryFull = join(dir, entry); + const entryRel = relPath ? `${relPath}/${entry}` : entry; + + let isDir = false; + try { + isDir = statSync(entryFull).isDirectory(); + } catch { + continue; + } + + if (isDir) { + walkDir(entryFull, entryRel, violations); + continue; + } + + if (!isTargetFile(entry)) continue; + if (wrapperFileSet.has(entryRel)) continue; + if (EXEMPT_FILES.has(entry)) continue; + + let content: string; + try { + content = readFileSync(entryFull, 'utf-8'); + } catch { + continue; + } + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + for (const { pattern, wrapper } of patterns) { + if (pattern.test(line)) { + violations.push({ file: entryRel, line: i + 1, content: line.trim(), wrapper }); + } + } + } + } +} + +const violations: Violation[] = []; +walkDir(EXPO_ROOT, '', violations); + +if (violations.length > 0) { + console.log( + `Direct imports of wrapped modules found (${violations.length}) — import from the lib/ wrapper instead:\n`, + ); + for (const { file, line, content, wrapper } of violations) { + console.log(`apps/expo/${file}:${line}: ${content}`); + console.log(` → import from '${wrapper}'\n`); + } + process.exit(1); +} + +console.log('No direct imports of wrapped modules in apps/expo.'); From 14d97a35e89cc31ea357b24d2629611e0af3a507 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 31 May 2026 20:09:07 -0600 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(expo):=20rout?= =?UTF-8?q?e=20call=20sites=20through=20lib/=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct imports of expo-secure-store, expo-apple-authentication, expo-updates, @react-native-async-storage/async-storage, @react-native-google-signin/google-signin, and expo-sqlite/kv-store with their apps/expo/lib/ wrappers across 19 files (call sites, jotai storage atoms, the API/auth clients, and the existing kvStorage/persist-plugin web wrappers). Behavior-preserving on native; the .web.ts variants now back web auth and storage so no Platform branches are needed. --- apps/expo/app/(app)/(tabs)/profile/index.tsx | 2 +- apps/expo/app/(app)/settings/index.tsx | 2 +- apps/expo/app/auth/index.tsx | 2 +- apps/expo/atoms/atomWithAsyncStorage.ts | 2 +- apps/expo/atoms/atomWithKvStorage.ts | 2 +- apps/expo/atoms/atomWithSecureStorage.ts | 2 +- apps/expo/features/ai/atoms/chatStorageAtoms.ts | 2 +- apps/expo/features/auth/hooks/useAuthActions.ts | 14 +++++--------- apps/expo/features/auth/hooks/useAuthInit.ts | 6 +++--- .../hooks/useTrailConditionReports.ts | 2 +- .../weather/screens/LocationSearchScreen.tsx | 2 +- apps/expo/features/wildlife/atoms/wildlifeAtoms.ts | 2 +- apps/expo/lib/api/packrat.ts | 2 +- apps/expo/lib/auth-client.ts | 2 +- apps/expo/lib/kvStorage.ts | 2 +- apps/expo/lib/kvStorage.web.ts | 2 +- apps/expo/lib/persist-plugin.ts | 2 +- apps/expo/lib/persist-plugin.web.ts | 2 +- apps/expo/utils/storage.ts | 2 +- 19 files changed, 25 insertions(+), 29 deletions(-) diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index 7972ce9a22..d495e9850b 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -13,7 +13,6 @@ import { ListSectionHeader, Text, } from '@packrat/ui/nativewindui'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; import { isLoadingAtom, suppressSignOutNavAtom } from 'expo-app/features/auth/atoms/authAtoms'; @@ -24,6 +23,7 @@ import { useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; import { uploadImage } from 'expo-app/features/packs/utils/uploadImage'; import { ProfileAuthWall } from 'expo-app/features/profile/components'; import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { cn } from 'expo-app/lib/cn'; import { hasUnsyncedChanges } from 'expo-app/lib/hasUnsyncedChanges'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index df17a19360..07c7b309dd 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -1,5 +1,4 @@ import { ActivityIndicator, Text } from '@packrat/ui/nativewindui'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Burnt from 'burnt'; import { appAlert } from 'expo-app/app/_layout'; import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; @@ -17,6 +16,7 @@ import { } from 'expo-app/features/ai/lib/localModelManager'; import { DeleteAccountButton } from 'expo-app/features/auth/components/DeleteAccountButton'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index 82dbff5f14..fa97cf7ad8 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -1,9 +1,9 @@ import type { AlertMethods } from '@packrat/ui/nativewindui'; import { ActivityIndicator, AlertAnchor, Button, Text } from '@packrat/ui/nativewindui'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { featureFlags } from 'expo-app/config'; import { needsReauthAtom, redirectToAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; import { Link, router, useLocalSearchParams } from 'expo-router'; diff --git a/apps/expo/atoms/atomWithAsyncStorage.ts b/apps/expo/atoms/atomWithAsyncStorage.ts index 49060418e7..9e033b4f3b 100644 --- a/apps/expo/atoms/atomWithAsyncStorage.ts +++ b/apps/expo/atoms/atomWithAsyncStorage.ts @@ -1,5 +1,5 @@ import { isFunction } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { atom } from 'jotai'; export const atomWithAsyncStorage = ({ diff --git a/apps/expo/atoms/atomWithKvStorage.ts b/apps/expo/atoms/atomWithKvStorage.ts index 886827e7d7..8a265c85cf 100644 --- a/apps/expo/atoms/atomWithKvStorage.ts +++ b/apps/expo/atoms/atomWithKvStorage.ts @@ -1,5 +1,5 @@ import { isFunction } from '@packrat/guards'; -import Storage from 'expo-sqlite/kv-store'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; import { atom } from 'jotai'; export const atomWithKvStorage = ({ key, initialValue }: { key: string; initialValue: T }) => { diff --git a/apps/expo/atoms/atomWithSecureStorage.ts b/apps/expo/atoms/atomWithSecureStorage.ts index b76543d317..da3c3ddcfe 100644 --- a/apps/expo/atoms/atomWithSecureStorage.ts +++ b/apps/expo/atoms/atomWithSecureStorage.ts @@ -1,5 +1,5 @@ import { isFunction } from '@packrat/guards'; -import * as SecureStore from 'expo-secure-store'; +import * as SecureStore from 'expo-app/lib/secureStore'; import { atom } from 'jotai'; export const atomWithSecureStorage = ({ diff --git a/apps/expo/features/ai/atoms/chatStorageAtoms.ts b/apps/expo/features/ai/atoms/chatStorageAtoms.ts index 7c9a90a856..33e74e11c7 100644 --- a/apps/expo/features/ai/atoms/chatStorageAtoms.ts +++ b/apps/expo/features/ai/atoms/chatStorageAtoms.ts @@ -1,6 +1,6 @@ import type { UIMessage } from '@ai-sdk/react'; import { isObject, isString } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; export type ChatContext = { itemId?: string; diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index d51be92553..eb21b9b149 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -1,22 +1,18 @@ import { asBoolean, asString } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - GoogleSignin, - isErrorWithCode, - statusCodes, -} from '@react-native-google-signin/google-signin'; import * as Sentry from '@sentry/react-native'; import { AuthClientError, toAuthError } from 'expo-app/features/auth/lib/authErrors'; import { userStore } from 'expo-app/features/auth/store'; import type { User } from 'expo-app/features/profile/types'; +import * as AppleAuthentication from 'expo-app/lib/appleAuthentication'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { authClient } from 'expo-app/lib/auth-client'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; +import { GoogleSignin, isErrorWithCode, statusCodes } from 'expo-app/lib/googleSignin'; import { t } from 'expo-app/lib/i18n'; +import * as Updates from 'expo-app/lib/updates'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; import { queryClient } from 'expo-app/providers/TanstackProvider'; -import * as AppleAuthentication from 'expo-apple-authentication'; import { type Href, router } from 'expo-router'; -import Storage from 'expo-sqlite/kv-store'; -import * as Updates from 'expo-updates'; import { useAtomValue, useSetAtom } from 'jotai'; import { isLoadingAtom, diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 2fb32a5431..a4773360ab 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -1,13 +1,13 @@ import { when } from '@legendapp/state'; import { clientEnvs } from '@packrat/env/expo-client'; import { asBoolean, asString } from '@packrat/guards'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { GoogleSignin } from '@react-native-google-signin/google-signin'; import * as Sentry from '@sentry/react-native'; import { userStore, userSyncState } from 'expo-app/features/auth/store'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { authClient } from 'expo-app/lib/auth-client'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; +import { GoogleSignin } from 'expo-app/lib/googleSignin'; import { router } from 'expo-router'; -import Storage from 'expo-sqlite/kv-store'; import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; diff --git a/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts b/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts index 5ac2853fc0..9d5c452df4 100644 --- a/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts +++ b/apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts @@ -1,8 +1,8 @@ import { useSelector } from '@legendapp/state/react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { useQuery } from '@tanstack/react-query'; import { userStore } from 'expo-app/features/auth/store/user'; import { apiClient } from 'expo-app/lib/api/packrat'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; import { useEffect, useRef, useState } from 'react'; import { trailConditionReportsStore } from '../store/trailConditionReports'; diff --git a/apps/expo/features/weather/screens/LocationSearchScreen.tsx b/apps/expo/features/weather/screens/LocationSearchScreen.tsx index f0365f95a7..48f2487584 100644 --- a/apps/expo/features/weather/screens/LocationSearchScreen.tsx +++ b/apps/expo/features/weather/screens/LocationSearchScreen.tsx @@ -1,7 +1,7 @@ import { Text } from '@packrat/ui/nativewindui'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { Icon } from 'expo-app/components/Icon'; import { SearchInput } from 'expo-app/components/SearchInput'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts b/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts index 7c53db4725..0e88a14228 100644 --- a/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts +++ b/apps/expo/features/wildlife/atoms/wildlifeAtoms.ts @@ -1,4 +1,4 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { atomWithStorage, createJSONStorage, loadable } from 'jotai/utils'; import type { WildlifeIdentification } from '../types'; diff --git a/apps/expo/lib/api/packrat.ts b/apps/expo/lib/api/packrat.ts index 94ae708bc5..5d60739c2c 100644 --- a/apps/expo/lib/api/packrat.ts +++ b/apps/expo/lib/api/packrat.ts @@ -4,7 +4,7 @@ import { fromZod } from '@packrat/guards'; import { store } from 'expo-app/atoms/store'; import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { authClient } from 'expo-app/lib/auth-client'; -import * as SecureStore from 'expo-secure-store'; +import * as SecureStore from 'expo-app/lib/secureStore'; import { z } from 'zod'; // The expoClient plugin serialises all cookies into SecureStore under this key. diff --git a/apps/expo/lib/auth-client.ts b/apps/expo/lib/auth-client.ts index b685923235..9108d07008 100644 --- a/apps/expo/lib/auth-client.ts +++ b/apps/expo/lib/auth-client.ts @@ -1,7 +1,7 @@ import { expoClient } from '@better-auth/expo/client'; import { clientEnvs } from '@packrat/env/expo-client'; import { createAuthClient } from 'better-auth/react'; -import * as SecureStore from 'expo-secure-store'; +import * as SecureStore from 'expo-app/lib/secureStore'; export const authClient = createAuthClient({ baseURL: clientEnvs.EXPO_PUBLIC_API_URL, diff --git a/apps/expo/lib/kvStorage.ts b/apps/expo/lib/kvStorage.ts index d21ac4e6e4..fb63ba1f1b 100644 --- a/apps/expo/lib/kvStorage.ts +++ b/apps/expo/lib/kvStorage.ts @@ -1,4 +1,4 @@ -import Storage from 'expo-sqlite/kv-store'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; export default { getItem: (key: string): string | null => Storage.getItemSync(key), diff --git a/apps/expo/lib/kvStorage.web.ts b/apps/expo/lib/kvStorage.web.ts index c9c9b9db28..611299e3cf 100644 --- a/apps/expo/lib/kvStorage.web.ts +++ b/apps/expo/lib/kvStorage.web.ts @@ -1,4 +1,4 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; export default { getItem: (key: string) => AsyncStorage.getItem(key), diff --git a/apps/expo/lib/persist-plugin.ts b/apps/expo/lib/persist-plugin.ts index 322561d52f..91fd460cb9 100644 --- a/apps/expo/lib/persist-plugin.ts +++ b/apps/expo/lib/persist-plugin.ts @@ -1,4 +1,4 @@ import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; -import Storage from 'expo-sqlite/kv-store'; +import Storage from 'expo-app/lib/expoSqliteKvStore'; export const persistPlugin = observablePersistSqlite(Storage); diff --git a/apps/expo/lib/persist-plugin.web.ts b/apps/expo/lib/persist-plugin.web.ts index a92c6d1914..aa77b260cf 100644 --- a/apps/expo/lib/persist-plugin.web.ts +++ b/apps/expo/lib/persist-plugin.web.ts @@ -1,5 +1,5 @@ import { observablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; // On web, the expo-sqlite persist plugin requires SharedArrayBuffer (COEP/COOP // headers). Use the AsyncStorage plugin instead, which falls through to our diff --git a/apps/expo/utils/storage.ts b/apps/expo/utils/storage.ts index abbab9a7da..f11446aeeb 100644 --- a/apps/expo/utils/storage.ts +++ b/apps/expo/utils/storage.ts @@ -1,5 +1,5 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import type { WeatherLocation } from 'expo-app/features/weather/types'; +import AsyncStorage from 'expo-app/lib/asyncStorage'; import { createJSONStorage } from 'jotai/utils'; // Create a storage adapter for Jotai that uses AsyncStorage From 625d67f1756394d016d991520fd2514866a08aaa Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 5 Jun 2026 12:15:17 -0600 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=A7=20chore(lint):=20exempt=20expo?= =?UTF-8?q?SqliteKvStore.web=20shim=20from=20owned-max-params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web shim mirrors expo-sqlite/kv-store's positional (key, value) API (setItem/setItemSync/setItemAsync), same as the secureStore.web shim. --- scripts/lint/no-owned-max-params.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/lint/no-owned-max-params.ts b/scripts/lint/no-owned-max-params.ts index 6ba66608fb..1a89c90569 100644 --- a/scripts/lint/no-owned-max-params.ts +++ b/scripts/lint/no-owned-max-params.ts @@ -46,6 +46,8 @@ const EXCLUDED_FILES = new Set([ 'apps/trails/scripts/generate-og-images.ts', // Web shim that must mirror expo-secure-store's positional (key, value) API. 'apps/expo/lib/secureStore.web.ts', + // Web shim that must mirror expo-sqlite/kv-store's positional (key, value) API. + 'apps/expo/lib/expoSqliteKvStore.web.ts', ]); const FRAMEWORK_METHOD_NAMES = new Set(['fetch', 'queue', 'resolveRequest', 'scheduled']); const EXTERNAL_CALLBACK_NAMES = new Set([ From 0a568920e7d9eaab70a388e9316a38fbcedcb5df Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 5 Jun 2026 18:49:54 -0600 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=91=B7=20ci(web-e2e):=20stand=20up=20?= =?UTF-8?q?local=20API=20stack=20instead=20of=20remote=20dev=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Web E2E job built the web app against a remote dev API (secrets.EXPO_PUBLIC_API_URL) and seeded the real dev DB. The remote API was unreachable from the runner, so Better Auth sign-in failed at globalSetup.ts ("Failed to fetch", status 0) and the whole job went red. Rework it to stand up the full stack locally, mirroring the Maestro job and the documented local web-e2e setup: - Start Postgres (pgvector) + the Neon HTTP proxy via packages/api/docker-compose.test.yml. The proxy lets wrangler dev's @neondatabase/serverless HTTP driver hit local Postgres on :4444 — the same driver path as prod, reliable under the web flow's query load (verified locally: repeated sign-ins 200, no DB timeouts). - Migrations + seed connect to the raw Postgres port (:5433) so migrate.ts uses the node-postgres driver; db.localtest.me:4444 is the HTTP proxy, not the pg wire protocol. Both target the same database. - Write .dev.vars with ENVIRONMENT=development so Better Auth trusts http://localhost:* origins (web app is served cross-origin on :8081), and NEON_DATABASE_URL pointed at the proxy. - Build the web bundle against the local API (http://localhost:8787), serve on :8081, run Playwright against it. - Drop the NEON_DEV_DATABASE_URL dependency from the e2e-gate; the stack is fully local now. Also trigger on packages/api/** changes. Verified locally end-to-end: docker stack up, migrate + seed via raw pg, wrangler dev healthy, and cross-origin POST /api/auth/sign-in/email returns 200 with Access-Control-Allow-Origin + an HttpOnly better-auth.session_token cookie. --- .github/workflows/web-e2e-tests.yml | 209 +++++++++++++++++++++++++--- 1 file changed, 193 insertions(+), 16 deletions(-) diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index ee58cc728d..ac15669910 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -9,6 +9,7 @@ on: - "!apps/expo/**/*.test.ts" - "!apps/expo/**/*.test.tsx" - "!apps/expo/vitest.config.ts" + - "packages/api/**" - ".github/workflows/web-e2e-tests.yml" # Note: Using `pull_request` (not `pull_request_target`) so forked PRs get # CI feedback on their own code. Secrets are unavailable for forks, so @@ -21,6 +22,7 @@ on: - "!apps/expo/**/*.test.ts" - "!apps/expo/**/*.test.tsx" - "!apps/expo/vitest.config.ts" + - "packages/api/**" - ".github/workflows/web-e2e-tests.yml" workflow_dispatch: @@ -31,6 +33,17 @@ concurrency: permissions: contents: read +env: + # The web app is served here; the local wrangler dev API is served on :8787. + WEB_URL: http://localhost:8081 + E2E_API_URL: http://localhost:8787 + # Migrations + seed run in Node and connect to the raw Postgres port directly + # (node-postgres driver). The wrangler dev worker instead talks to the same + # database through the Neon HTTP proxy at db.localtest.me:4444 (see .dev.vars + # below) so it exercises the exact prod code path with no node-postgres TCP + # sockets in workerd. Both URLs point at the same underlying database. + E2E_DB_DIRECT_URL: postgres://test_user:test_password@127.0.0.1:5433/packrat_test + jobs: e2e-gate: name: Check E2E prerequisites @@ -43,13 +56,17 @@ jobs: name: Verify E2E secrets are available env: E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + E2E_BETTER_AUTH_SECRET: ${{ secrets.E2E_BETTER_AUTH_SECRET }} run: | - if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$NEON_DATABASE_URL" ]; then + # The stack is fully local (Dockerized Postgres + wrangler dev), so the + # only secrets needed are the seeded test user's credentials and the + # Better Auth signing secret. No cloud DB dependency. + if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$E2E_TEST_PASSWORD" ] && [ -n "$E2E_BETTER_AUTH_SECRET" ]; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" - echo "::notice::E2E secrets not configured — skipping Web E2E tests" + echo "::notice::E2E secrets not configured — skipping Web E2E tests (need E2E_TEST_EMAIL + E2E_TEST_PASSWORD + E2E_BETTER_AUTH_SECRET)" fi web-e2e: @@ -60,7 +77,7 @@ jobs: timeout-minutes: 30 env: - # The E2E user is upserted into the dev DB by the seed step below, + # The E2E user is upserted into the local DB by the seed step below, # so both email and password are driven entirely by repo secrets. TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} @@ -88,26 +105,166 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install --frozen-lockfile + - name: Add node_modules bin to PATH + run: echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH" + - name: Install Playwright browsers run: bunx playwright install chromium --with-deps + # ── Local API stack ────────────────────────────────────────────────────── + # Postgres (pgvector) + the Neon HTTP proxy run as Docker containers from + # packages/api/docker-compose.test.yml. The proxy lets wrangler dev's + # @neondatabase/serverless HTTP driver hit local Postgres on :4444 — the + # same driver path as prod, which is far more reliable under the web flow's + # query load than node-postgres-in-workerd. + + - name: Map db.localtest.me to loopback + # localtest.me already resolves to 127.0.0.1 publicly; pinning it in + # /etc/hosts removes the external-DNS dependency from CI. + run: echo "127.0.0.1 db.localtest.me" | sudo tee -a /etc/hosts + + - name: Start Postgres + Neon HTTP proxy + run: | + docker compose -f packages/api/docker-compose.test.yml up -d --build \ + postgres-test neon-proxy + + echo "Waiting for Postgres to be ready..." + for i in $(seq 1 30); do + if docker compose -f packages/api/docker-compose.test.yml exec -T \ + postgres-test pg_isready -U test_user -d packrat_test > /dev/null 2>&1; then + echo "Postgres ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "::error::Postgres did not become ready in time" + docker compose -f packages/api/docker-compose.test.yml logs postgres-test + exit 1 + fi + sleep 2 + done + + echo "Waiting for Neon HTTP proxy on :4444..." + for i in $(seq 1 30); do + # The proxy answers /sql to POSTs; any HTTP response means it is up. + if curl -s -o /dev/null "http://db.localtest.me:4444/sql"; then + echo "Neon proxy ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "::error::Neon HTTP proxy did not become ready in time" + docker compose -f packages/api/docker-compose.test.yml logs neon-proxy + exit 1 + fi + sleep 2 + done + + - name: Write wrangler .dev.vars + env: + E2E_BETTER_AUTH_SECRET: ${{ secrets.E2E_BETTER_AUTH_SECRET }} + # Optional: real weather keys improve trip-detail test fidelity. + # Fall back to schema-valid stubs — weather errors are non-fatal. + WEATHER_API_KEY: ${{ secrets.WEATHER_API_KEY }} + OPENWEATHER_KEY: ${{ secrets.OPENWEATHER_KEY }} + run: | + # Static (non-secret) vars first. ENVIRONMENT=development makes Better + # Auth trust http://localhost:* origins (the web app is served from a + # different origin than the API), and NEON_DATABASE_URL points at the + # Neon HTTP proxy so the worker uses the prod driver path. Schema-valid + # stubs cover APIs not exercised by the web flow so env-validation + # does not throw. + cat > packages/api/.dev.vars << 'DEVVARS' + ENVIRONMENT=development + NEON_DATABASE_URL=postgres://test_user:test_password@db.localtest.me:4444/packrat_test + NEON_DATABASE_URL_READONLY=postgres://test_user:test_password@db.localtest.me:4444/packrat_test + BETTER_AUTH_URL=http://localhost:8787 + EXPO_PUBLIC_API_URL=http://localhost:8787 + ADMIN_USERNAME=admin + ADMIN_PASSWORD=gobuffs + PACKRAT_API_KEY=secret + EMAIL_PROVIDER=resend + RESEND_API_KEY=re_e2e_stub_not_used_in_web_flows + EMAIL_FROM=no-reply@e2e.packrattest.local + AI_PROVIDER=openai + OPENAI_API_KEY=sk-e2e-stub-not-used-in-web-flow + GOOGLE_GENERATIVE_AI_API_KEY=e2e-stub-not-used + PERPLEXITY_API_KEY=pplx-e2e-stub-not-used-in-web-flow + GOOGLE_CLIENT_ID=e2e-google-client-id + GOOGLE_CLIENT_SECRET=e2e-google-client-secret + APPLE_CLIENT_ID=com.packratai.e2e + APPLE_PRIVATE_KEY=e2e-apple-private-key-stub + APPLE_KEY_ID=E2EAPLKEY1 + APPLE_TEAM_ID=E2ETEAMID1 + CLOUDFLARE_ACCOUNT_ID=e2eaccountid + CLOUDFLARE_AI_GATEWAY_ID=ai-chat-gateway + R2_ACCESS_KEY_ID=e2er2accesskey + R2_SECRET_ACCESS_KEY=e2er2secretkey1234567890abcdefghij + PACKRAT_BUCKET_R2_BUCKET_NAME=packrat-bucket-e2e + PACKRAT_GUIDES_BUCKET_R2_BUCKET_NAME=packrat-guides-e2e + PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME=packrat-scrapy-e2e + R2_PUBLIC_URL=https://pub-e2estub.r2.dev + PACKRAT_GUIDES_RAG_NAME=packrat-guides-rag + PACKRAT_GUIDES_BASE_URL=https://guides.packratai.com/ + DEVVARS + + # Inject secrets separately (printf avoids shell expansion on special chars). + printf 'BETTER_AUTH_SECRET=%s\n' "${E2E_BETTER_AUTH_SECRET}" \ + >> packages/api/.dev.vars + printf 'WEATHER_API_KEY=%s\n' \ + "${WEATHER_API_KEY:-e2e-fake-weather-key}" \ + >> packages/api/.dev.vars + printf 'OPENWEATHER_KEY=%s\n' \ + "${OPENWEATHER_KEY:-e2e-fake-weather-key}" \ + >> packages/api/.dev.vars + + - name: Run DB migrations + env: + # Direct raw-Postgres connection: migrate.ts uses the node-postgres + # driver for non-Neon hosts. (db.localtest.me:4444 is the HTTP proxy, + # not the pg wire protocol, so migrations must use the raw port.) + NEON_DATABASE_URL: ${{ env.E2E_DB_DIRECT_URL }} + run: bun run --filter @packrat/api db:migrate + + - name: Seed E2E test user + env: + NEON_DATABASE_URL: ${{ env.E2E_DB_DIRECT_URL }} + E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ env.TEST_PASSWORD }} + run: bun run --filter @packrat/api db:seed:e2e-user + + - name: Start wrangler dev (background) + working-directory: packages/api + run: | + WRANGLER_LOG=warn \ + WRANGLER_SEND_METRICS=false \ + wrangler dev -e dev --ip 0.0.0.0 --local --enable-containers=false \ + > /tmp/wrangler-dev.log 2>&1 & + echo "WRANGLER_PID=$!" >> "$GITHUB_ENV" + + echo "Waiting for API to be ready on ${E2E_API_URL}/health ..." + RETRIES=30 + until curl -sf "${E2E_API_URL}/health" > /dev/null; do + RETRIES=$((RETRIES - 1)) + if [ "$RETRIES" -le 0 ]; then + echo "::error::wrangler dev did not become ready in time" + cat /tmp/wrangler-dev.log + exit 1 + fi + sleep 2 + done + echo "API ready." + - name: Build Expo web app working-directory: apps/expo env: - EXPO_PUBLIC_API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} + # Bake the local wrangler dev URL into the bundle so the browser app + # calls the API stood up above (not a remote deployment). + EXPO_PUBLIC_API_URL: ${{ env.E2E_API_URL }} EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: ${{ secrets.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID }} EXPO_PUBLIC_R2_PUBLIC_URL: ${{ secrets.EXPO_PUBLIC_R2_PUBLIC_URL }} EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ secrets.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY }} run: bunx expo export -p web --output-dir dist - - name: Seed E2E test user in dev DB - run: bun run --filter @packrat/api db:seed:e2e-user - env: - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} - E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} - E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} - - name: Serve web app (SPA mode, port 8081) working-directory: apps/expo # -s routes all 404s to index.html for client-side routing @@ -116,7 +273,7 @@ jobs: - name: Wait for web server run: | for i in $(seq 1 30); do - curl -sf http://localhost:8081 && echo "Server ready" && break + curl -sf "${WEB_URL}" > /dev/null && echo "Server ready" && break echo "Waiting... ($i/30)" sleep 2 done @@ -124,9 +281,8 @@ jobs: - name: Run Playwright E2E tests working-directory: apps/expo env: - BASE_URL: http://localhost:8081 - API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + BASE_URL: ${{ env.WEB_URL }} + API_URL: ${{ env.E2E_API_URL }} CI: "true" run: bun test:web @@ -145,3 +301,24 @@ jobs: name: playwright-traces path: apps/expo/test-results/ retention-days: 7 + + - name: Upload wrangler dev log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: web-wrangler-dev-log + path: /tmp/wrangler-dev.log + if-no-files-found: ignore + retention-days: 7 + + - name: Dump container logs on failure + if: failure() + run: docker compose -f packages/api/docker-compose.test.yml logs --no-color || true + + - name: Stop wrangler dev + if: always() + run: kill "${WRANGLER_PID:-}" 2>/dev/null || true + + - name: Tear down Docker stack + if: always() + run: docker compose -f packages/api/docker-compose.test.yml down -v || true