Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/web-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions apps/expo/features/trips/store/trips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion apps/expo/features/trips/utils/getTripDetailOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions apps/expo/lib/api/packrat.web.ts
Original file line number Diff line number Diff line change
@@ -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);
},
Comment on lines +18 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify authClient.getSession() return shape and existing usage patterns before applying.
set -euo pipefail

echo "== Locate authClient definition =="
fd -i "auth-client" apps | sed -n '1,20p'

echo "== Inspect getSession call sites with context =="
rg -n -C3 --type=ts --type=tsx '\bauthClient\.getSession\s*\('

echo "== Inspect createApiClient auth hooks typing/contracts =="
rg -n -C3 --type=ts --type=tsx '\bonNeedsReauth\b|getAccessToken|getRefreshToken|onAccessTokenRefreshed' apps packages

Repository: PackRat-AI/PackRat

Length of output: 280


🏁 Script executed:

# Read the auth-client.ts to understand getSession return type
cat -n apps/expo/lib/auth-client.ts | head -50

Repository: PackRat-AI/PackRat

Length of output: 1036


🏁 Script executed:

# Read the packrat.web.ts file to see the complete context
cat -n apps/expo/lib/api/packrat.web.ts

Repository: PackRat-AI/PackRat

Length of output: 1340


🏁 Script executed:

# Search for all getSession usages in the codebase (fixing the file type issue)
rg -n -C3 'getSession\s*\(' apps packages --type ts -A 5 -B 2

Repository: PackRat-AI/PackRat

Length of output: 13294


🏁 Script executed:

# Check if there's a better-auth package and its version
fd package.json apps packages | head -5 | xargs grep -l "better-auth" 2>/dev/null || echo "Checking root package.json"
cat package.json | grep -A5 -B5 "better-auth" 2>/dev/null || echo "No better-auth found in root"

Repository: PackRat-AI/PackRat

Length of output: 405


Handle errors from authClient.getSession() before setting reauth state.

onNeedsReauth ignores the error field from authClient.getSession(). If the call fails transiently (network issue, service error), the code sets needsReauthAtom = true and bounces the user to login unnecessarily. The codebase already acknowledges this risk in a similar call at apps/expo/lib/api/packrat.ts line 42–46. The auth initialization code in useAuthInit.ts shows the correct pattern: check error first and return early if present.

Suggested fix
 onNeedsReauth: async () => {
-  const { data } = await authClient.getSession();
-  if (data?.session) return;
+  const { data, error } = await authClient.getSession();
+  if (error) return;
+  if (data?.session) return;
   store.set(needsReauthAtom, true);
 },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/lib/api/packrat.web.ts` around lines 18 - 22, onNeedsReauth
currently calls authClient.getSession() and sets needsReauthAtom without
checking for an error; change it to examine the returned { data, error } from
authClient.getSession() and return early if error is present (do not set
store.set(needsReauthAtom, true)) so transient failures don't force reauth.
Locate the onNeedsReauth handler and update the logic to: await
authClient.getSession(), check error first, then check data?.session and only
set needsReauthAtom when there is no error and no session (mirroring the pattern
used in useAuthInit and the similar logic in packrat.ts).

},
});

export type PackRatApi = typeof apiClient;
6 changes: 3 additions & 3 deletions apps/expo/lib/utils/__tests__/getRelativeTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,21 @@ 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<string, unknown>) => `${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');
});

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');
});
});
4 changes: 2 additions & 2 deletions apps/expo/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions apps/expo/playwright/tests/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand All @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down
55 changes: 49 additions & 6 deletions apps/expo/playwright/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json');

interface CachedAuth {
accessToken: string;
refreshToken: string;
refreshToken: string | null;
user: Record<string, unknown> | null;
}

Expand All @@ -23,30 +23,73 @@ 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<BrowserContext> {
const { accessToken, refreshToken, user } = loadCachedAuth();

const localStorage = [
const localStorage: { name: string; value: string }[] = [
{ name: 'access_token', value: accessToken },
{ name: 'refresh_token', value: refreshToken },
// Skip the auth version migration that clears access_token / refresh_token.
{ name: 'auth_version', value: 'v2' },
];

if (refreshToken) {
localStorage.push({ name: 'refresh_token', value: refreshToken });
}

if (user) {
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 };
Expand Down
144 changes: 91 additions & 53 deletions apps/expo/playwright/tests/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).user : null;
let user: Record<string, unknown> | 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<string, unknown> }).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({
Expand All @@ -39,76 +47,106 @@ 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/login`, {
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 { accessToken, refreshToken, user } = (await loginRes.json()) as {
accessToken: string;
refreshToken: string;
const { token: accessToken, user } = (await loginRes.json()) as {
token: string;
user: Record<string, unknown>;
};
fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, 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<string, unknown>;
};
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;
Loading
Loading