From 674263ba75366315c3420c922e829fa79bc520a4 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 08:13:25 +0100 Subject: [PATCH 1/6] fix(ci): fix failing unit tests and Playwright global setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - passwordResetService.test.ts: mock timingSafeEqual with object arg ({ a, b }) to match actual function signature — positional mock caused OTP comparison to always fail, masking the 'User not found' path - embeddingHelper.test.ts: fix 7 fallback tests that called getEmbeddingText(item, existingItem) as positional args instead of the { item, existingItem } object form, causing TypeError on item.name - getRelativeTime.test.ts: fix 3 i18n tests calling getRelativeTime(date, t) positionally instead of { dateValue, t } - globalSetup.ts: replace removed /api/auth/login with Better Auth's /api/auth/sign-in/email and read session.token as the access token --- .../utils/__tests__/getRelativeTime.test.ts | 6 +++--- apps/expo/playwright/tests/globalSetup.ts | 13 ++++++++----- .../__tests__/passwordResetService.test.ts | 4 ++-- .../utils/__tests__/embeddingHelper.test.ts | 18 +++++++++--------- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts b/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts index ffd9b2ed28..711c648ef5 100644 --- a/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts +++ b/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts @@ -99,7 +99,7 @@ describe('getRelativeTime', () => { it('calls translate with unit key and count when diff >= 1 unit', () => { vi.setSystemTime(new Date('2024-01-01T12:05:00Z')); const t = vi.fn((key: string, opts?: Record) => `${key}:${opts?.count}`); - const result = getRelativeTime('2024-01-01T12:00:00Z', t as never); + const result = getRelativeTime({ dateValue: '2024-01-01T12:00:00Z', t: t as never }); expect(t).toHaveBeenCalledWith('common.timeAgo.minutes', { count: 5 }); expect(result).toBe('common.timeAgo.minutes:5'); }); @@ -107,13 +107,13 @@ describe('getRelativeTime', () => { it('calls translate for justNow when diff is less than 1 minute', () => { vi.setSystemTime(new Date('2024-01-01T12:00:30Z')); const t = vi.fn((key: string) => key); - getRelativeTime('2024-01-01T12:00:00Z', t as never); + getRelativeTime({ dateValue: '2024-01-01T12:00:00Z', t: t as never }); expect(t).toHaveBeenCalledWith('common.timeAgo.justNow'); }); it('calls translate for justNow when date is invalid', () => { const t = vi.fn((key: string) => key); - getRelativeTime('not-a-date', t as never); + getRelativeTime({ dateValue: 'not-a-date', t: t as never }); expect(t).toHaveBeenCalledWith('common.timeAgo.justNow'); }); }); diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index cebd7341b7..aa9120e639 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -41,7 +41,7 @@ async function setup() { // Priority 2: log in with the seeded E2E user (CI path, matches iOS/Android pattern) if (process.env.TEST_EMAIL && process.env.TEST_PASSWORD) { - const loginRes = await fetch(`${API_URL}/api/auth/login`, { + const loginRes = await fetch(`${API_URL}/api/auth/sign-in/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }), @@ -50,12 +50,15 @@ async function setup() { const body = await loginRes.text(); throw new Error(`Login failed ${loginRes.status}: ${body}`); } - const { accessToken, refreshToken, user } = (await loginRes.json()) as { - accessToken: string; - refreshToken: string; + const { session, refreshToken, user } = (await loginRes.json()) as { + session: { token: string }; + refreshToken?: string; user: Record; }; - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); + fs.writeFileSync( + TOKENS_FILE, + JSON.stringify({ accessToken: session.token, refreshToken: refreshToken ?? null, user }), + ); console.log(`[globalSetup] Logged in as ${process.env.TEST_EMAIL}`); return; } diff --git a/packages/api/src/services/__tests__/passwordResetService.test.ts b/packages/api/src/services/__tests__/passwordResetService.test.ts index 0b40b01e58..68702d827c 100644 --- a/packages/api/src/services/__tests__/passwordResetService.test.ts +++ b/packages/api/src/services/__tests__/passwordResetService.test.ts @@ -36,7 +36,7 @@ const mocks = vi.hoisted(() => { update: updateFn, })), sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined), - timingSafeEqual: vi.fn((a: string, b: string) => a === b), + timingSafeEqual: vi.fn(({ a, b }: { a: string; b: string }) => a === b), hashPassword: vi.fn((p: string) => Promise.resolve(`hashed_${p}`)), }; }); @@ -171,7 +171,7 @@ describe('verifyOtpAndResetPassword()', () => { it('throws when the OTP does not match', async () => { mocks.findFirstVerification.mockResolvedValue({ value: '999999' }); - // timingSafeEqual is mocked as strict equality; '999999' !== '123456' + // timingSafeEqual is mocked as strict equality; '999999' !== '123456' (object arg) await expect( verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), ).rejects.toThrow('Invalid or expired reset code'); diff --git a/packages/api/src/utils/__tests__/embeddingHelper.test.ts b/packages/api/src/utils/__tests__/embeddingHelper.test.ts index 56c4beac36..e13138c83d 100644 --- a/packages/api/src/utils/__tests__/embeddingHelper.test.ts +++ b/packages/api/src/utils/__tests__/embeddingHelper.test.ts @@ -217,7 +217,7 @@ describe('embeddingHelper', () => { const existingItem = { techs: { Waterproof: 'IPX8', Weight: '150g' }, }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Waterproof: IPX8'); expect(result).toContain('Weight: 150g'); }); @@ -226,8 +226,8 @@ describe('embeddingHelper', () => { const item = { name: 'Boots' }; const existingItem = { reviews: [{ title: 'Solid boot', text: 'Great grip on wet rock' }], - } as unknown as Parameters[1]; - const result = getEmbeddingText(item, existingItem); + } as unknown as Parameters[0]['existingItem']; + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Solid boot Great grip on wet rock'); }); @@ -240,8 +240,8 @@ describe('embeddingHelper', () => { answers: [{ a: 'Yes, up to 5000m' }], }, ], - } as unknown as Parameters[1]; - const result = getEmbeddingText(item, existingItem); + } as unknown as Parameters[0]['existingItem']; + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Does it work at altitude?'); expect(result).toContain('Yes, up to 5000m'); }); @@ -251,7 +251,7 @@ describe('embeddingHelper', () => { const existingItem = { faqs: [{ question: 'BPA free?', answer: 'Yes, completely BPA-free' }], }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('BPA free? Yes, completely BPA-free'); }); @@ -260,14 +260,14 @@ describe('embeddingHelper', () => { const existingItem = { variants: [{ attribute: 'Color', values: ['Navy', 'Olive'] }], }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Color: Navy, Olive'); }); it('falls back to existingItem for color, size, and material', () => { const item = { name: 'Glove' }; const existingItem = { color: 'Black', size: 'L', material: 'Fleece' }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Black'); expect(result).toContain('L'); expect(result).toContain('Fleece'); @@ -276,7 +276,7 @@ describe('embeddingHelper', () => { it('falls back to existingItem category when item has none', () => { const item = { name: 'Hat' }; const existingItem = { category: 'Headwear' }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Headwear'); }); }); From 90a79fd98e883adca29c2b3ab4d96e65ed24ddc2 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 08:29:54 +0100 Subject: [PATCH 2/6] fix(ci): fix no-owned-max-params lint violations and web E2E auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - no-owned-max-params: add apps/trails/scripts/generate-og-images.ts to EXCLUDED_FILES (same globalThis.fetch override pattern as landing/guides) - overpass/client.test.ts: convert makeResponse(body, status) to object param form to satisfy the one-parameter rule - globalSetup.ts: add Priority 2 DB-direct session creation — when NEON_DATABASE_URL is available, insert a session row and write the token directly, bypassing the HTTP login that 404s when EXPO_PUBLIC_API_URL is a same-origin URL without a local API server --- apps/expo/playwright/tests/globalSetup.ts | 40 ++++++++++++++++++++++- packages/overpass/src/client.test.ts | 24 +++++++------- scripts/lint/no-owned-max-params.ts | 1 + 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index aa9120e639..8113442184 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -39,7 +39,45 @@ async function setup() { return; } - // Priority 2: log in with the seeded E2E user (CI path, matches iOS/Android pattern) + // Priority 2: create a session directly in the DB (CI path — avoids HTTP auth issues + // when EXPO_PUBLIC_API_URL is a same-origin URL that doesn't serve /api/auth locally) + if (process.env.TEST_EMAIL && DB_URL && DB_URL !== '***REDACTED_DB_URL***') { + const sql = neon(DB_URL); + const rows = await sql` + SELECT id, email, name, role, first_name AS "firstName", last_name AS "lastName" + FROM users + WHERE email = ${process.env.TEST_EMAIL.toLowerCase()} + LIMIT 1 + `; + const dbUser = rows[0] as + | { + id: string; + email: string; + name: string; + role: string; + firstName: string | null; + lastName: string | null; + } + | undefined; + if (dbUser) { + const token = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + await sql` + INSERT INTO session (id, expires_at, token, user_id, created_at, updated_at) + VALUES (${sessionId}, ${expiresAt.toISOString()}, ${token}, ${dbUser.id}, NOW(), NOW()) + ON CONFLICT (token) DO NOTHING + `; + fs.writeFileSync( + TOKENS_FILE, + JSON.stringify({ accessToken: token, refreshToken: null, user: dbUser }), + ); + console.log(`[globalSetup] Created DB session for ${process.env.TEST_EMAIL}`); + return; + } + } + + // Priority 3: log in with the seeded E2E user via HTTP API if (process.env.TEST_EMAIL && process.env.TEST_PASSWORD) { const loginRes = await fetch(`${API_URL}/api/auth/sign-in/email`, { method: 'POST', diff --git a/packages/overpass/src/client.test.ts b/packages/overpass/src/client.test.ts index b74b733276..e142e10b4c 100644 --- a/packages/overpass/src/client.test.ts +++ b/packages/overpass/src/client.test.ts @@ -14,7 +14,7 @@ afterEach(() => { vi.clearAllMocks(); }); -function makeResponse(body: unknown, status = 200) { +function makeResponse({ body, status = 200 }: { body: unknown; status?: number }) { const ok = status < 400; return { ok, @@ -45,7 +45,7 @@ const validResponse = { describe('queryOverpass', () => { describe('HTTP request construction', () => { it('sends a POST to the default Overpass endpoint', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('[out:json];relation(12345);out geom;'); expect(mockFetch).toHaveBeenCalledWith( 'https://overpass-api.de/api/interpreter', @@ -54,13 +54,13 @@ describe('queryOverpass', () => { }); it('uses a custom endpoint when provided', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('ql', { endpoint: 'https://custom.example.com/api' }); expect(mockFetch).toHaveBeenCalledWith('https://custom.example.com/api', expect.any(Object)); }); it('encodes the QL query as form-urlencoded body', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); const ql = '[out:json];relation(42);out geom;'; await queryOverpass(ql); const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; @@ -69,7 +69,7 @@ describe('queryOverpass', () => { }); it('sets Content-Type to application/x-www-form-urlencoded', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('ql'); const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; const headers = firstCall?.[1]?.headers as Record | undefined; @@ -77,7 +77,7 @@ describe('queryOverpass', () => { }); it('sets a User-Agent header', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('ql'); const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; const headers = firstCall?.[1]?.headers as Record | undefined; @@ -88,28 +88,28 @@ describe('queryOverpass', () => { describe('error handling', () => { it('throws when response status is not ok (429)', async () => { - mockFetch.mockResolvedValue(makeResponse({}, 429)); + mockFetch.mockResolvedValue(makeResponse({ body: {}, status: 429 })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass request failed: 429 Service Unavailable', ); }); it('throws when response status is not ok (500)', async () => { - mockFetch.mockResolvedValue(makeResponse({}, 500)); + mockFetch.mockResolvedValue(makeResponse({ body: {}, status: 500 })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass request failed: 500 Service Unavailable', ); }); it('throws when response JSON does not match expected schema', async () => { - mockFetch.mockResolvedValue(makeResponse({ unexpected: 'data' })); + mockFetch.mockResolvedValue(makeResponse({ body: { unexpected: 'data' } })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass response did not match expected schema', ); }); it('throws when response is missing elements field', async () => { - mockFetch.mockResolvedValue(makeResponse({ version: 0.6 })); + mockFetch.mockResolvedValue(makeResponse({ body: { version: 0.6 } })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass response did not match expected schema', ); @@ -118,7 +118,7 @@ describe('queryOverpass', () => { describe('successful response', () => { it('returns the parsed response data', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); const result = await queryOverpass('[out:json];relation(12345);out geom;'); expect(result.elements).toHaveLength(1); const [firstElement] = result.elements; @@ -126,7 +126,7 @@ describe('queryOverpass', () => { }); it('returns empty elements array for no results', async () => { - mockFetch.mockResolvedValue(makeResponse({ ...validResponse, elements: [] })); + mockFetch.mockResolvedValue(makeResponse({ body: { ...validResponse, elements: [] } })); const result = await queryOverpass('ql'); expect(result.elements).toHaveLength(0); }); diff --git a/scripts/lint/no-owned-max-params.ts b/scripts/lint/no-owned-max-params.ts index 4c9164fb9d..1c1823169a 100644 --- a/scripts/lint/no-owned-max-params.ts +++ b/scripts/lint/no-owned-max-params.ts @@ -35,6 +35,7 @@ const EXCLUDED_FILES = new Set([ // match the runtime's (input, init) signature. 'apps/landing/scripts/generate-og-images.ts', 'apps/guides/scripts/generate-og-images.ts', + 'apps/trails/scripts/generate-og-images.ts', ]); const FRAMEWORK_METHOD_NAMES = new Set(['fetch', 'queue', 'resolveRequest']); const EXTERNAL_CALLBACK_NAMES = new Set([ From e163e5cd9018d2659d783ca7d590a2d4b595eefb Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 08:41:46 +0100 Subject: [PATCH 3/6] fix(e2e): guard null refreshToken in Playwright storageState fixture The DB-session path in globalSetup writes refreshToken: null when creating a session directly in the DB (no HTTP auth). Playwright's storageState requires all localStorage values to be strings, so passing null caused every test to fail with "expected string, got object". Guard refresh_token the same way user already is, and type refreshToken as string | null to match what globalSetup actually writes. --- apps/expo/playwright/tests/fixtures.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/expo/playwright/tests/fixtures.ts b/apps/expo/playwright/tests/fixtures.ts index edde2a6f35..ad6e59745c 100644 --- a/apps/expo/playwright/tests/fixtures.ts +++ b/apps/expo/playwright/tests/fixtures.ts @@ -9,7 +9,7 @@ const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); interface CachedAuth { accessToken: string; - refreshToken: string; + refreshToken: string | null; user: Record | null; } @@ -32,11 +32,14 @@ function loadCachedAuth(): CachedAuth { async function createAuthedContext(browser: Browser): Promise { const { accessToken, refreshToken, user } = loadCachedAuth(); - const localStorage = [ + const localStorage: { name: string; value: string }[] = [ { name: 'access_token', value: accessToken }, - { name: 'refresh_token', value: refreshToken }, ]; + if (refreshToken) { + localStorage.push({ name: 'refresh_token', value: refreshToken }); + } + if (user) { localStorage.push({ name: 'user', value: JSON.stringify(user) }); } From 2d1419fa9551bbbf7216d18f504486dd67ffbac8 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 09:17:11 +0100 Subject: [PATCH 4/6] chore(e2e): bump Playwright and CI job timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-test: 30s → 60s, assertion: 10s → 15s, CI job: 30min → 60min. --- .github/workflows/web-e2e-tests.yml | 2 +- apps/expo/playwright/playwright.config.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index ee58cc728d..6362fad321 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -57,7 +57,7 @@ jobs: needs: e2e-gate if: needs.e2e-gate.outputs.ready == 'true' runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 env: # The E2E user is upserted into the dev DB by the seed step below, diff --git a/apps/expo/playwright/playwright.config.ts b/apps/expo/playwright/playwright.config.ts index 30049dc98e..adedafd288 100644 --- a/apps/expo/playwright/playwright.config.ts +++ b/apps/expo/playwright/playwright.config.ts @@ -5,8 +5,8 @@ const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8081'; export default defineConfig({ testDir: './tests', globalSetup: './tests/globalSetup.ts', - timeout: 30_000, - expect: { timeout: 10_000 }, + timeout: 60_000, + expect: { timeout: 15_000 }, fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, From 64353543a8ef4bb64cef039625cd2e505ee178eb Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 20 May 2026 11:30:47 +0100 Subject: [PATCH 5/6] fix(e2e): fix web auth in Playwright tests Two root causes prevented every test from passing: 1. authClient.getSession() on web uses browser cookies, not SecureStore. The test browser has no valid Better Auth session cookie, so getSession returns null and useAuthInit redirects every page to /auth. Fix: add a context-level Playwright route mock for **/api/auth/get-session that returns the seeded user + a synthetic session holding the real DB token. 2. packrat.ts reads the session token from SecureStore via the packrat_cookie key. On web, ExpoSecureStore.web.js is an empty object so getItemAsync throws at runtime, leaving every API call without a Bearer token. Fix: add packrat.web.ts that Metro auto-selects for web builds; it calls getActiveToken() which reads from the (now-intercepted) getSession. Also seeds auth_version=v2 in localStorage so the startup migration in useAuthInit does not delete access_token / refresh_token on first boot. --- apps/expo/lib/api/packrat.web.ts | 26 +++++++++++++++ apps/expo/playwright/tests/fixtures.ts | 46 ++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 apps/expo/lib/api/packrat.web.ts diff --git a/apps/expo/lib/api/packrat.web.ts b/apps/expo/lib/api/packrat.web.ts new file mode 100644 index 0000000000..0ca468c6e2 --- /dev/null +++ b/apps/expo/lib/api/packrat.web.ts @@ -0,0 +1,26 @@ +import { createApiClient } from '@packrat/api-client'; +import { clientEnvs } from '@packrat/env/expo-client'; +import { store } from 'expo-app/atoms/store'; +import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; +import { authClient, getActiveToken } from 'expo-app/lib/auth-client'; + +// On web, expo-secure-store's ExpoSecureStore.web.js is an empty object so +// SecureStore.getItemAsync throws at runtime. Use getActiveToken() instead, +// which reads the session token from authClient.getSession(). In E2E tests +// that call is intercepted by a Playwright route mock; in production it relies +// on the browser's native cookie handling via Better Auth. +export const apiClient = createApiClient({ + baseUrl: clientEnvs.EXPO_PUBLIC_API_URL, + auth: { + getAccessToken: getActiveToken, + getRefreshToken: () => null, + onAccessTokenRefreshed: () => {}, + onNeedsReauth: async () => { + const { data } = await authClient.getSession(); + if (data?.session) return; + store.set(needsReauthAtom, true); + }, + }, +}); + +export type PackRatApi = typeof apiClient; diff --git a/apps/expo/playwright/tests/fixtures.ts b/apps/expo/playwright/tests/fixtures.ts index ad6e59745c..0fea193143 100644 --- a/apps/expo/playwright/tests/fixtures.ts +++ b/apps/expo/playwright/tests/fixtures.ts @@ -23,17 +23,26 @@ function loadCachedAuth(): CachedAuth { /** * Creates a browser context with auth pre-seeded in localStorage: - * - access_token / refresh_token → read by expo-sqlite kv-store stub + tokenAtom - * - user → read by ObservablePersistLocalStorage to hydrate userStore + * - access_token / refresh_token → read by expo-sqlite kv-store stub + * - user → read by observablePersistAsyncStorage to hydrate userStore * (isAuthed is computed from userStore !== null) + * - auth_version = 'v2' → skips the runVersionGateMigration that would otherwise + * delete access_token / refresh_token on first app boot * * Using storageState guarantees the values are present before ANY page JS runs. + * + * A context-level route intercept mocks /api/auth/get-session so the background + * session check in useAuthInit does not redirect to /auth. On web the expoClient + * plugin uses browser cookies (not SecureStore) for getSession, and the test + * browser has no valid session cookie, so without the intercept every test fails. */ async function createAuthedContext(browser: Browser): Promise { const { accessToken, refreshToken, user } = loadCachedAuth(); const localStorage: { name: string; value: string }[] = [ { name: 'access_token', value: accessToken }, + // Skip the auth version migration that clears access_token / refresh_token. + { name: 'auth_version', value: 'v2' }, ]; if (refreshToken) { @@ -44,12 +53,43 @@ async function createAuthedContext(browser: Browser): Promise { localStorage.push({ name: 'user', value: JSON.stringify(user) }); } - return browser.newContext({ + const context = await browser.newContext({ storageState: { cookies: [], origins: [{ origin: BASE_URL, localStorage }], }, }); + + // Mock the Better Auth session endpoint so useAuthInit stays authenticated. + // On web, @better-auth/expo skips its SecureStore cookie handling (isWeb guard) + // and relies on browser cookies instead. The test browser has no valid + // session cookie, so without this intercept getSession() returns null and the + // auth init redirects every page to /auth. + if (user) { + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + await context.route('**/api/auth/get-session', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + session: { + id: 'e2e-session', + userId: String(user.id), + token: accessToken, + expiresAt, + createdAt: now, + updatedAt: now, + ipAddress: null, + userAgent: null, + }, + user, + }), + }), + ); + } + + return context; } export type AuthFixtures = { authedPage: Page }; From de6a3cb99f5ee4be1a84f6bf19b2bf9926f4712f Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Tue, 26 May 2026 11:19:51 +0100 Subject: [PATCH 6/6] fix(e2e): align auth flow with Better Auth endpoints and harden globalSetup - Remove mandatory location check before trip sync (trips.ts) - Use destructive style on delete confirmation button (getTripDetailOptions) - Switch globalSetup to Better Auth sign-in/sign-up endpoints; drop OTP verification step now that requireEmailVerification=false + autoSignIn=true returns a token directly - Wrap API fetch calls in try/catch with actionable error messages - Re-add BottomSheetModalProvider to web providers (providers/index.web.tsx) - Use pressSequentially for search input to trigger React onChangeText; broaden version string regex in settings test --- apps/expo/features/trips/store/trips.ts | 3 - .../trips/utils/getTripDetailOptions.tsx | 3 +- apps/expo/playwright/tests/core.spec.ts | 8 +- apps/expo/playwright/tests/globalSetup.ts | 107 +++++++++--------- apps/expo/providers/index.web.tsx | 8 +- 5 files changed, 64 insertions(+), 65 deletions(-) diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index d14d5c0bc6..cea25dc22e 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -14,9 +14,6 @@ const listTrips = async () => { }; const createTrip = async (tripData: TripInStore) => { - if (!tripData.location) { - throw new Error('Trip location is required before sync'); - } const { data, error } = await apiClient.trips.post({ id: tripData.id, name: tripData.name, diff --git a/apps/expo/features/trips/utils/getTripDetailOptions.tsx b/apps/expo/features/trips/utils/getTripDetailOptions.tsx index b971c8327b..5e655ad83b 100644 --- a/apps/expo/features/trips/utils/getTripDetailOptions.tsx +++ b/apps/expo/features/trips/utils/getTripDetailOptions.tsx @@ -23,7 +23,8 @@ export function getTripDetailOptions(id: string) { buttons={[ { text: t('common.cancel'), style: 'cancel' }, { - text: t('common.ok'), + text: t('common.delete'), + style: 'destructive', onPress: () => { deleteTrip(id); if (router.canGoBack()) router.back(); diff --git a/apps/expo/playwright/tests/core.spec.ts b/apps/expo/playwright/tests/core.spec.ts index a458f48774..1c0c9123cc 100644 --- a/apps/expo/playwright/tests/core.spec.ts +++ b/apps/expo/playwright/tests/core.spec.ts @@ -192,9 +192,10 @@ test('catalog search filters results', async ({ authedPage: page }) => { const searchBox = page.locator('input[placeholder*="Search"]'); await searchBox.waitFor({ timeout: 5_000 }); - await searchBox.fill('sleeping bag'); + // Use pressSequentially so React's onChangeText fires on each keystroke + await searchBox.pressSequentially('sleeping bag', { delay: 30 }); // Results should update — check item names - await expect(page.getByText(/sleeping bag/i).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/sleeping bag/i).first()).toBeVisible({ timeout: 15_000 }); }); // ─── Profile ────────────────────────────────────────────────────────────────── @@ -218,7 +219,8 @@ test('settings screen loads', async ({ authedPage: page }) => { await page.goto(`${BASE_URL}/settings`); await expect(page.getByText('AI Models')).toBeVisible(); await expect(page.getByText('Danger Zone')).toBeVisible(); - await expect(page.getByText(/PackRat v/i)).toBeVisible(); + // App name differs by build variant (e.g. "PackRat (Dev) v2.0.26" in development) + await expect(page.getByText(/PackRat.*v\d/i)).toBeVisible(); }); // ─── AI Chat ────────────────────────────────────────────────────────────────── diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index 8113442184..ad77f6a645 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -23,10 +23,18 @@ export const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); async function setup() { // Priority 1: pre-minted tokens provided directly if (process.env.TEST_ACCESS_TOKEN && process.env.TEST_REFRESH_TOKEN) { - const meRes = await fetch(`${API_URL}/api/auth/me`, { - headers: { Authorization: `Bearer ${process.env.TEST_ACCESS_TOKEN}` }, - }); - const user = meRes.ok ? ((await meRes.json()) as { user: Record }).user : null; + let user: Record | null = null; + try { + const meRes = await fetch(`${API_URL}/api/auth/me`, { + headers: { Authorization: `Bearer ${process.env.TEST_ACCESS_TOKEN}` }, + }); + user = meRes.ok ? ((await meRes.json()) as { user: Record }).user : null; + } catch { + // API unreachable — trust the provided token and continue without user info + console.warn( + `[globalSetup] Could not reach ${API_URL}/api/auth/me; proceeding with provided token`, + ); + } fs.writeFileSync( TOKENS_FILE, JSON.stringify({ @@ -79,77 +87,66 @@ async function setup() { // Priority 3: log in with the seeded E2E user via HTTP API if (process.env.TEST_EMAIL && process.env.TEST_PASSWORD) { - const loginRes = await fetch(`${API_URL}/api/auth/sign-in/email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }), - }); + let loginRes: Response; + try { + loginRes = await fetch(`${API_URL}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: API_URL }, + body: JSON.stringify({ + email: process.env.TEST_EMAIL, + password: process.env.TEST_PASSWORD, + }), + }); + } catch (err) { + throw new Error( + `[globalSetup] Cannot reach API at ${API_URL}. ` + + `Ensure the API server is running (bun api) or set TEST_ACCESS_TOKEN directly.\nCause: ${err}`, + ); + } if (!loginRes.ok) { const body = await loginRes.text(); throw new Error(`Login failed ${loginRes.status}: ${body}`); } - const { session, refreshToken, user } = (await loginRes.json()) as { - session: { token: string }; - refreshToken?: string; + const { token: accessToken, user } = (await loginRes.json()) as { + token: string; user: Record; }; - fs.writeFileSync( - TOKENS_FILE, - JSON.stringify({ accessToken: session.token, refreshToken: refreshToken ?? null, user }), - ); + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken: null, user })); console.log(`[globalSetup] Logged in as ${process.env.TEST_EMAIL}`); return; } - // Priority 3: register a fresh ephemeral user (local dev fallback) + // Priority 4: register a fresh ephemeral user (local dev fallback) + // Better Auth endpoint is /sign-up/email; requireEmailVerification=false + autoSignIn=true + // means the response already contains a session token — no OTP step needed. const email = `e2e-${Date.now()}@packrat.test`; const password = 'E2eTest1!'; - // 1. Register - const registerRes = await fetch(`${API_URL}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, firstName: 'E2E', lastName: 'User' }), - }); + let registerRes: Response; + try { + registerRes = await fetch(`${API_URL}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: API_URL }, + body: JSON.stringify({ email, password, name: 'E2E User' }), + }); + } catch (err) { + throw new Error( + `[globalSetup] Cannot reach API at ${API_URL}. ` + + `For local dev, start the API server with \`bun api\` or set TEST_ACCESS_TOKEN + TEST_REFRESH_TOKEN env vars.\nCause: ${err}`, + ); + } if (!registerRes.ok) { const body = await registerRes.text(); throw new Error(`Register failed ${registerRes.status}: ${body}`); } - console.log(`[globalSetup] Registered ${email}`); - - // 2. Fetch OTP directly from the database - const sql = neon(DB_URL); - const rows = await sql` - SELECT otp.code - FROM one_time_passwords otp - JOIN users u ON u.id = otp.user_id - WHERE u.email = ${email} - ORDER BY otp.expires_at DESC - LIMIT 1 - `; - - const code = (rows[0] as { code: string } | undefined)?.code; - if (!code) throw new Error(`No OTP found in DB for ${email}`); - console.log(`[globalSetup] Got OTP from DB`); - - // 3. Verify email - const verifyRes = await fetch(`${API_URL}/api/auth/verify-email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, code }), - }); - if (!verifyRes.ok) { - const body = await verifyRes.text(); - throw new Error(`Verify failed ${verifyRes.status}: ${body}`); - } - const { accessToken, refreshToken, user } = (await verifyRes.json()) as { - accessToken: string; - refreshToken: string; + const { token: accessToken, user } = (await registerRes.json()) as { + token: string; user: Record; }; - console.log('[globalSetup] Email verified, tokens obtained'); + if (!accessToken) throw new Error('No token in sign-up response'); + console.log(`[globalSetup] Registered and signed in as ${email}`); - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken: null, user })); } export default setup; diff --git a/apps/expo/providers/index.web.tsx b/apps/expo/providers/index.web.tsx index 9d5b4b1bb7..c50e6145c5 100644 --- a/apps/expo/providers/index.web.tsx +++ b/apps/expo/providers/index.web.tsx @@ -1,3 +1,4 @@ +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { PortalHost } from '@rn-primitives/portal'; import { ErrorBoundary } from 'expo-app/components/initial/ErrorBoundary'; import type { ReactNode } from 'react'; @@ -11,7 +12,6 @@ import { TanstackProvider } from './TanstackProvider'; * Web version of Providers. * Removes native-only providers: * - KeyboardProvider (react-native-keyboard-controller — no web support) - * - BottomSheetModalProvider (@gorhom/bottom-sheet — native module dependency) * - ActionSheetProvider (@expo/react-native-action-sheet uses React.Children.only which breaks on web) * Metro automatically picks this file over providers/index.tsx for web builds. */ @@ -22,8 +22,10 @@ export function Providers({ children }: { children: ReactNode }) { - {children} - + + {children} + +