From 9eb14aa5c0bf0cf243afccf534903ad5dad0b808 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:17:38 -0600 Subject: [PATCH 01/14] :whale: chore(api): add local-neon-http-proxy on :4444 to test stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ghcr.io/timowilhelm/local-neon-http-proxy lets the @neondatabase/serverless HTTP and WS drivers talk to local Postgres on a single port. We add it alongside the existing wsproxy so the Playwright E2E setup (wrangler dev against db.localtest.me) shares the exact code paths Better Auth and Eden Treaty use in prod — no driver/adapter switching. --- packages/api/docker-compose.test.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/api/docker-compose.test.yml b/packages/api/docker-compose.test.yml index dad926c705..61eb9346fc 100644 --- a/packages/api/docker-compose.test.yml +++ b/packages/api/docker-compose.test.yml @@ -23,8 +23,8 @@ services: -c log_duration=on -c max_connections=100 - # Neon-compatible WebSocket proxy so tests use the same @neondatabase/serverless - # driver as production — eliminates pg-cloudflare / cloudflare:sockets workarounds. + # Neon-compatible WebSocket proxy used by the vitest integration suite + # (`packages/api/test/setup.ts`) — speaks WebSocket only. wsproxy: image: ghcr.io/neondatabase/wsproxy:latest environment: @@ -37,5 +37,21 @@ services: postgres-test: condition: service_healthy + # Official Neon HTTP+WS local proxy + # (https://neon.com/guides/local-development-with-neon). Lets the + # `@neondatabase/serverless` HTTP driver (neon(url)) and WebSocket Pool + # talk to local Postgres on a single port (4444), so wrangler dev hits the + # exact same code paths as prod — no per-request WebSocket lifetime issues. + # Used by the Playwright E2E stack. + neon-proxy: + image: ghcr.io/timowilhelm/local-neon-http-proxy:main + environment: + PG_CONNECTION_STRING: postgres://test_user:test_password@postgres-test:5432/packrat_test + ports: + - "4444:4444" + depends_on: + postgres-test: + condition: service_healthy + volumes: postgres_test_data: \ No newline at end of file From b732454827b937246c3dc549865b80e12acb730d Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:18:47 -0600 Subject: [PATCH 02/14] :bug: fix(api): wire local dev to db.localtest.me proxy + CORS the auth routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces that together let `wrangler dev` work from the local web app: 1. `maybeConfigureLocalNeon` in the Worker entry flips `neonConfig.fetchEndpoint` (HTTP /sql) and `neonConfig.wsProxy` (WS /v2) at port 4444 when NEON_DATABASE_URL points at db.localtest.me. Combined with skipping the db.localtest.me host in src/db/index.ts' isStandardPostgresUrl check, every component (Better Auth + Eden Treaty + queue workers) routes through the same neon-http driver as prod. Eliminates the "Cannot perform I/O on behalf of a different request" hang from cross-request Pool WebSocket reuse. 2. CORS preflight handler for /api/auth/* — Better Auth's handler returns 404 on OPTIONS, so browser-based sign-in from a different origin (Expo web on :8082, API on :8787) never reached Better Auth. We mirror the Elysia CORS allowlist (packrat.world subdomains, localhost, exp://) and append Access-Control headers to Better Auth's response too. --- packages/api/src/db/index.ts | 10 ++++- packages/api/src/index.ts | 81 +++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index 28c5f0ba8b..5daf38ddf8 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -13,8 +13,16 @@ const isStandardPostgresUrl = (url: string) => { const host = u.hostname.toLowerCase(); const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + // `db.localtest.me` is the hostname the local Neon HTTP proxy uses (see + // packages/api/docker-compose.test.yml). The URL looks like raw Postgres + // but the proxy fronts the real connection and speaks Neon's HTTP/WS + // wire format, so we route through the neon driver — same path as prod. + const isLocalNeonProxy = host === 'db.localtest.me'; return ( - (u.protocol === 'postgres:' || u.protocol === 'postgresql:') && !isNeonTech && !isNeonCom + (u.protocol === 'postgres:' || u.protocol === 'postgresql:') && + !isNeonTech && + !isNeonCom && + !isLocalNeonProxy ); } catch { return false; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0ff3fa9edd..5b8e7fc3de 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,6 +8,7 @@ import type { MessageBatch } from '@cloudflare/workers-types'; import { cors } from '@elysiajs/cors'; +import { neonConfig } from '@neondatabase/serverless'; import { getAuth } from '@packrat/api/auth'; import { AppContainer } from '@packrat/api/containers'; import { routes } from '@packrat/api/routes'; @@ -20,6 +21,35 @@ import { Elysia } from 'elysia'; import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker'; import type { CatalogETLMessage } from './services/etl/types'; +// Local-dev hook: route `@neondatabase/serverless` through Neon's official +// local proxy (`ghcr.io/timowilhelm/local-neon-http-proxy`, recommended by +// https://neon.com/guides/local-development-with-neon) when NEON_DATABASE_URL +// points at the local `db.localtest.me` host. The proxy serves both the HTTP +// /sql API (used by neon-http, which is what auth/index.ts uses) and the +// WebSocket /v2 endpoint (used by neon-serverless Pool) — so prod and local +// share the exact same driver code paths with no adapter switch. +let neonLocalConfigured = false; +function maybeConfigureLocalNeon(databaseUrl: string): void { + if (neonLocalConfigured) return; + try { + const u = new URL(databaseUrl); + const host = u.hostname.toLowerCase(); + const isNeon = + host === 'neon.tech' || + host.endsWith('.neon.tech') || + host === 'neon.com' || + host.endsWith('.neon.com'); + if (isNeon) return; + const proxyPort = process.env.NEON_LOCAL_PROXY_PORT ?? '4444'; + neonConfig.fetchEndpoint = (h) => + h === 'db.localtest.me' ? `http://${h}:${proxyPort}/sql` : `https://${h}/sql`; + neonConfig.wsProxy = (h) => (h === 'db.localtest.me' ? `${h}:${proxyPort}/v2` : `${h}/v2`); + neonConfig.useSecureWebSocket = host !== 'db.localtest.me'; + } finally { + neonLocalConfigured = true; + } +} + export const app = new Elysia({ adapter: CloudflareAdapter }) .use( cors({ @@ -92,13 +122,62 @@ export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const e = enrichEnv(env); setWorkerEnv(e as unknown as Record); // safe-cast: setWorkerEnv accepts Record; ValidatedEnv has no index signature by design + maybeConfigureLocalNeon(e.NEON_DATABASE_URL); // Route /api/auth/** to Better Auth before Elysia sees it. const url = new URL(request.url); if (url.pathname.startsWith('/api/auth')) { + // Better Auth does not implement CORS preflight (OPTIONS) responses, so + // we mirror the Elysia CORS allowlist here. Without this, browser-based + // sign-in calls from the web app (a different origin than the API) fail + // the preflight and never reach Better Auth. + const origin = request.headers.get('Origin'); + const isAllowedOrigin = + !!origin && + [ + /^https:\/\/(www\.)?packrat\.world$/, + /^https:\/\/[\w-]+\.packrat\.world$/, + /^https:\/\/[\w-]+\.packratai\.com$/, + /^https?:\/\/[\w-]+\.workers\.dev$/, + /^http:\/\/localhost:\d+$/, + /^exp:\/\//, + ].some((re) => re.test(origin)); + + const corsHeaders: Record = isAllowedOrigin + ? { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + Vary: 'Origin', + } + : {}; + + if (request.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + ...corsHeaders, + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': + request.headers.get('Access-Control-Request-Headers') ?? + 'Content-Type, Authorization, X-API-Key', + 'Access-Control-Max-Age': '86400', + }, + }); + } + const validatedEnv = getEnv(); const auth = await getAuth(validatedEnv); - return auth.handler(request); + const authResponse = await auth.handler(request); + if (!isAllowedOrigin) return authResponse; + // Copy Better Auth's response and append CORS headers so cookies/JSON + // payloads reach the cross-origin caller. + const headers = new Headers(authResponse.headers); + for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v); + return new Response(authResponse.body, { + status: authResponse.status, + statusText: authResponse.statusText, + headers, + }); } return (app.fetch as unknown as CfFetchFn)(request, e, ctx); // safe-cast: Elysia's fetch has Cloudflare-specific env/ctx params not in the standard type From ddf005f823b0626d07afb6675c7543477de3809c Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:18:56 -0600 Subject: [PATCH 03/14] :closed_lock_with_key: fix(api/auth): trust localhost origins when API URL is localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better Auth's `trustedOrigins` previously only allowed the API base URL itself (`http://localhost:8787`), so sign-in calls from the Expo web app at `http://localhost:8082` were rejected by the CSRF check. Gate the extra entries on `env.BETTER_AUTH_URL.startsWith('http://localhost')` so the trust list never widens in production. (Can't use ENVIRONMENT here — its Zod default is 'production' when unset, which would dead-code this.) --- packages/api/src/auth/index.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 7c488119c4..7da9937fb9 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -199,7 +199,20 @@ export async function getAuth(env: ValidatedEnv): Promise { storage: 'secondary-storage', }, - trustedOrigins: [env.BETTER_AUTH_URL, 'packrat://'], + trustedOrigins: [ + env.BETTER_AUTH_URL, + 'packrat://', + // Local web dev (Expo web on 8081/8082, Next admin on 3000). Gated on + // the API URL pointing at localhost so prod never widens trust. + ...(env.BETTER_AUTH_URL.startsWith('http://localhost') + ? [ + 'http://localhost:8081', + 'http://localhost:8082', + 'http://localhost:3000', + 'http://localhost:19006', + ] + : []), + ], }); authCache.set(env as object, auth); From a4906727dc51029515a2331b622c5553a533d69e Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:19:04 -0600 Subject: [PATCH 04/14] :seedling: fix(api/seed): create account row alongside users so Better Auth can sign in the E2E user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better Auth's email/password sign-in reads the password from `account.password` (with `providerId='credential'`), not `users.password_hash`. The 0042 data-migration copies users → account at migrate time, before any user exists; the E2E user is created *after* that migration runs, so it had no credential account row and sign-in returned 401 against a fresh local DB. This commit also captures the user id from the create branch so we don't re-query for it before inserting the account row. --- packages/api/src/db/seed-e2e-user.ts | 40 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/api/src/db/seed-e2e-user.ts b/packages/api/src/db/seed-e2e-user.ts index 0a7a23dcbe..65feab5b68 100644 --- a/packages/api/src/db/seed-e2e-user.ts +++ b/packages/api/src/db/seed-e2e-user.ts @@ -11,7 +11,7 @@ import { neon, neonConfig } from '@neondatabase/serverless'; import { nodeEnv } from '@packrat/env/node'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Client } from 'pg'; @@ -64,13 +64,15 @@ async function seedE2EUser() { .where(eq(schema.users.email, normalizedEmail)) .limit(1); + let userId: string; const existingUser = existing[0]; if (existingUser) { + userId = existingUser.id; await db .update(schema.users) .set({ passwordHash, emailVerified: true, updatedAt: new Date() }) - .where(eq(schema.users.id, existingUser.id)); - console.log(`E2E user refreshed: ${normalizedEmail} (id=${existingUser.id})`); + .where(eq(schema.users.id, userId)); + console.log(`E2E user refreshed: ${normalizedEmail} (id=${userId})`); } else { const [inserted] = await db .insert(schema.users) @@ -85,7 +87,37 @@ async function seedE2EUser() { role: 'USER', }) .returning(); - console.log(`E2E user created: ${normalizedEmail} (id=${inserted?.id})`); + if (!inserted) throw new Error('users insert returned no row'); + userId = inserted.id; + console.log(`E2E user created: ${normalizedEmail} (id=${userId})`); + } + + // Better Auth's email/password sign-in reads the password from the + // `account` table (providerId='credential'), not `users.password_hash`. + // The 0042 data-migration only copies users → account at migrate time, so + // any user created after that migration (this seed, fresh dev DBs) needs + // an explicit credential row to be sign-in-able. + const existingAccount = await db + .select({ id: schema.account.id }) + .from(schema.account) + .where(and(eq(schema.account.userId, userId), eq(schema.account.providerId, 'credential'))) + .limit(1); + + if (existingAccount[0]) { + await db + .update(schema.account) + .set({ password: passwordHash, updatedAt: new Date() }) + .where(eq(schema.account.id, existingAccount[0].id)); + console.log(`Credential account refreshed for ${normalizedEmail}`); + } else { + await db.insert(schema.account).values({ + id: crypto.randomUUID(), + accountId: userId, + providerId: 'credential', + userId, + password: passwordHash, + }); + console.log(`Credential account created for ${normalizedEmail}`); } } finally { await pgClient?.end(); From e6fe8b0a3dc5b976046354462e8e44773f44ea4e Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:19:11 -0600 Subject: [PATCH 05/14] :seedling: feat(api/seed): add catalog seed for E2E (10 hand-picked items, OpenAI embeddings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production populates `catalog_items` via the ETL workflow scraping product pages. A fresh local DB has zero rows, which breaks any test that scrolls the catalog tab, runs a similarity search, or adds an item from catalog to a pack. Ten hand-picked items spanning Sleep System / Shelter / Packs / Cooking / Lighting / Water / Apparel give the catalog tab something to render and make "sleeping bag" a meaningful search query. Embeddings are generated once via OpenAI text-embedding-3-small (matches the schema's 1536-dim vector column). Idempotent on SKU — safe to re-run. Usage: NEON_DATABASE_URL=... OPENAI_API_KEY=... \ bun run packages/api/src/db/seed-e2e-catalog.ts --- packages/api/src/db/seed-e2e-catalog.ts | 214 ++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 packages/api/src/db/seed-e2e-catalog.ts diff --git a/packages/api/src/db/seed-e2e-catalog.ts b/packages/api/src/db/seed-e2e-catalog.ts new file mode 100644 index 0000000000..3c0725db67 --- /dev/null +++ b/packages/api/src/db/seed-e2e-catalog.ts @@ -0,0 +1,214 @@ +/** + * Seed a handful of catalog items so the catalog-tab and catalog-search + * Playwright tests have data to render and filter against. Idempotent on + * SKU — re-running won't duplicate rows. + * + * Usage: + * NEON_DATABASE_URL= OPENAI_API_KEY= \ + * bun run packages/api/src/db/seed-e2e-catalog.ts + * + * Why this script exists: in production the `catalog_items` table is + * populated by the ETL workflow scraping product pages. Local dev DBs + * (docker-compose.test.yml) start empty, so anything that scrolls the + * catalog tab or runs a similarity search has nothing to find. + */ + +import { neon, neonConfig } from '@neondatabase/serverless'; +import { nodeEnv } from '@packrat/env/node'; +import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; +import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { Client } from 'pg'; +import WebSocket from 'ws'; +import * as schema from './schema'; + +neonConfig.webSocketConstructor = WebSocket; + +const isStandardPostgresUrl = (url: string) => { + try { + const u = new URL(url); + const host = u.hostname.toLowerCase(); + const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); + const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + return u.protocol === 'postgres:' && !isNeonTech && !isNeonCom; + } catch { + return false; + } +}; + +type SeedItem = { + sku: string; + name: string; + description: string; + weight: number; + weightUnit: 'g' | 'oz'; + categories: string[]; + brand?: string; +}; + +// A small, hand-picked set covering distinct gear categories so the catalog +// search test ("sleeping bag") and the add-from-catalog test (just needs at +// least one item) both have meaningful hits. +const ITEMS: SeedItem[] = [ + { + sku: 'e2e-sleeping-bag-20f', + name: 'Mountain Loft Down Sleeping Bag 20°F', + description: 'Lightweight down sleeping bag rated to 20°F for 3-season backpacking.', + weight: 980, + weightUnit: 'g', + categories: ['Sleep System'], + brand: 'TrailLab', + }, + { + sku: 'e2e-sleeping-bag-30f', + name: 'Sierra Lite Sleeping Bag 30°F', + description: 'Compact synthetic sleeping bag for summer hiking.', + weight: 720, + weightUnit: 'g', + categories: ['Sleep System'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-tent-2p', + name: 'Cascade 2P Backpacking Tent', + description: 'Two-person freestanding tent, 1.6 kg packed weight, double-wall.', + weight: 1600, + weightUnit: 'g', + categories: ['Shelter'], + brand: 'TrailLab', + }, + { + sku: 'e2e-backpack-55l', + name: '55L Hiking Backpack', + description: 'Internal frame pack with hydration sleeve and rain cover.', + weight: 1500, + weightUnit: 'g', + categories: ['Packs'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-stove-canister', + name: 'Spark Mini Canister Stove', + description: 'Pocket-sized canister stove, boils 500ml in 3:30.', + weight: 78, + weightUnit: 'g', + categories: ['Cooking'], + }, + { + sku: 'e2e-headlamp', + name: 'Beam 350 Headlamp', + description: 'Rechargeable 350-lumen headlamp with red-light mode.', + weight: 68, + weightUnit: 'g', + categories: ['Lighting'], + }, + { + sku: 'e2e-water-filter', + name: 'Squeeze Water Filter', + description: 'Hollow-fiber filter that removes 99.99% of bacteria and protozoa.', + weight: 90, + weightUnit: 'g', + categories: ['Water'], + }, + { + sku: 'e2e-rain-jacket', + name: 'Stormshield Rain Jacket', + description: 'Three-layer waterproof breathable shell with pit zips.', + weight: 320, + weightUnit: 'g', + categories: ['Apparel'], + }, + { + sku: 'e2e-puffy', + name: 'Featherlite 800-Fill Down Jacket', + description: 'Insulated puffy jacket for cold belays and camp wear.', + weight: 290, + weightUnit: 'g', + categories: ['Apparel'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-sleeping-pad', + name: 'Aircell Insulated Sleeping Pad', + description: 'R-value 4.2 inflatable sleeping pad, 460 g packed.', + weight: 460, + weightUnit: 'g', + categories: ['Sleep System'], + }, +]; + +async function embedAll(values: string[], openAiKey: string): Promise { + const res = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${openAiKey}`, + }, + body: JSON.stringify({ model: 'text-embedding-3-small', input: values }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`OpenAI embeddings failed ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as { data: Array<{ embedding: number[] }> }; + return json.data.map((d) => d.embedding); +} + +async function seedCatalog() { + const dbUrl = nodeEnv.NEON_DATABASE_URL; + const openAiKey = process.env.OPENAI_API_KEY; + if (!dbUrl) throw new Error('NEON_DATABASE_URL is required'); + if (!openAiKey) throw new Error('OPENAI_API_KEY is required'); + + type SeedDatabase = NodePgDatabase | NeonHttpDatabase; + let db: SeedDatabase; + let pgClient: Client | undefined; + + if (isStandardPostgresUrl(dbUrl)) { + pgClient = new Client({ connectionString: dbUrl }); + await pgClient.connect(); + db = drizzlePg(pgClient, { schema }); + } else { + db = drizzle(neon(dbUrl), { schema }); + } + + try { + const existing = await db.select({ sku: schema.catalogItems.sku }).from(schema.catalogItems); + const knownSkus = new Set(existing.map((r) => r.sku)); + const newItems = ITEMS.filter((i) => !knownSkus.has(i.sku)); + if (newItems.length === 0) { + console.log(`Catalog already seeded (${existing.length} rows).`); + return; + } + + console.log(`Generating ${newItems.length} embeddings via OpenAI...`); + const embeddings = await embedAll( + newItems.map((i) => `${i.name}. ${i.description}`), + openAiKey, + ); + + for (let i = 0; i < newItems.length; i++) { + const item = newItems[i]; + const embedding = embeddings[i]; + if (!item || !embedding) continue; + await db.insert(schema.catalogItems).values({ + name: item.name, + productUrl: `https://example.com/${item.sku}`, + sku: item.sku, + weight: item.weight, + weightUnit: item.weightUnit, + description: item.description, + categories: item.categories, + brand: item.brand ?? null, + embedding, + }); + } + console.log(`Seeded ${newItems.length} catalog items.`); + } finally { + await pgClient?.end(); + } +} + +seedCatalog().catch((err) => { + console.error(err); + process.exit(1); +}); From 19808f1f6f6869491812000bba77ece5d4836798 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:19:20 -0600 Subject: [PATCH 06/14] :white_check_mark: test(e2e): chrome channel + headed-local + parallel workers in playwright config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `channel: 'chrome'` so the suite runs against the system Chrome — Playwright's bundled chromium has no Ubuntu 26.04 build yet, so installing the default browser would fail on newer Linux dev boxes. - `--incognito` / `--no-default-browser-check` / `--no-first-run` / `--password-store=basic` make the browser explicitly ephemeral so it never reads from the developer's personal Chrome profile. - Headless follows env: `CI=true` or `PWHEADLESS=1` → headless; otherwise headed locally so you can watch the run. - `fullyParallel: true` and 4 workers (override with `PW_WORKERS`) — tests use timestamped names so parallel runs don't collide. - globalSetup uses the same chrome channel + headless toggle so its initial sign-in matches the test browser environment. --- apps/expo/playwright/playwright.config.ts | 28 +++++++++++++++++++---- apps/expo/playwright/tests/globalSetup.ts | 7 +++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/expo/playwright/playwright.config.ts b/apps/expo/playwright/playwright.config.ts index 30049dc98e..ccfdf50a88 100644 --- a/apps/expo/playwright/playwright.config.ts +++ b/apps/expo/playwright/playwright.config.ts @@ -7,23 +7,43 @@ export default defineConfig({ globalSetup: './tests/globalSetup.ts', timeout: 30_000, expect: { timeout: 10_000 }, - fullyParallel: false, + // Tests create their own data (timestamped names) and otherwise read shared + // catalog/profile data, so parallel runs are safe. Override with + // PW_WORKERS=1 if you suspect a flake is contention-related. + fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: 1, + workers: Number(process.env.PW_WORKERS ?? (process.env.CI ? 2 : 4)), reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: BASE_URL, trace: 'on-first-retry', video: 'on-first-retry', - headless: true, + // Headless by default in CI; headed locally so you can watch the run. + // Override with PWHEADLESS=1 to force headless locally. + headless: !!process.env.CI || process.env.PWHEADLESS === '1', }, projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + // `channel: 'chrome'` uses the locally installed Google Chrome — + // Playwright's bundled chromium has no Ubuntu 26.04 build yet. + // Playwright already isolates each context with an ephemeral user-data + // dir, but `--incognito` makes that explicit. + use: { + ...devices['Desktop Chrome'], + channel: 'chrome', + launchOptions: { + args: [ + '--incognito', + '--no-default-browser-check', + '--no-first-run', + '--password-store=basic', + ], + }, + }, }, ], }); diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index 6373f7e635..3fed8a686e 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -15,7 +15,12 @@ export default async function setup() { fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); - const browser = await chromium.launch(); + const headless = !!process.env.CI || process.env.PWHEADLESS === '1'; + const browser = await chromium.launch({ + channel: 'chrome', + headless, + args: ['--incognito', '--no-default-browser-check', '--no-first-run', '--password-store=basic'], + }); const context = await browser.newContext(); const page = await context.newPage(); From 2b684d0d4e53e62923e779d2e0634e49301124f8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:19:28 -0600 Subject: [PATCH 07/14] :adhesive_bandage: test(e2e): settings regex tolerates "(Dev)" build tag + chat waits for session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The settings screen renders `PackRat (Dev) v2.0.26` in dev/preview builds but `PackRat v2.0.26` in production. Widen the regex to accept an optional `(...)` environment marker so the same assertion works on every build variant. - The AI chat transport's Authorization header is sourced from `authClient.useSession()`, which returns null on first render and only populates after the first `/api/auth/get-session` response resolves and React commits the new state. The test was clicking Send before that cycle finished, sending `Bearer null` → 401. Wait for the session network call AND a 500ms React-settle before clicking. --- apps/expo/playwright/tests/core.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/expo/playwright/tests/core.spec.ts b/apps/expo/playwright/tests/core.spec.ts index 56dec8bd5a..eade856656 100644 --- a/apps/expo/playwright/tests/core.spec.ts +++ b/apps/expo/playwright/tests/core.spec.ts @@ -239,7 +239,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(); + // Dev/preview builds prepend an environment tag, e.g. "PackRat (Dev) v2.0.26" + await expect(page.getByText(/PackRat(?: \([^)]+\))? v\d/i)).toBeVisible(); }); // ─── AI Chat ────────────────────────────────────────────────────────────────── @@ -269,6 +270,21 @@ test('AI chat sends message and gets response', async ({ authedPage: page }) => // Send a message await page.getByRole('textbox', { name: /Ask about this pack/i }).fill('List 3 essential items.'); + // The chat transport's Authorization header is sourced from + // `authClient.useSession()`, which starts as null and resolves on the first + // GET /api/auth/get-session. We need both: the network response AND a beat + // for React to flush the state update so the memoized transport rebuilds + // with the real token. Without the post-response settle, the send fires + // with `Bearer null` and the server returns 401. + await page + .waitForResponse((r) => r.url().includes('/api/auth/get-session') && r.status() === 200, { + timeout: 10_000, + }) + .catch(() => { + // Session may already be cached on the page — fall through. + }); + // Let React commit the session-loaded state into the chat transport. + await page.waitForTimeout(500); // Send button is icon-only with no accessible name; use the arrow-up icon character await page.getByText('󰁝').click(); From a2079f8df436ae6ede3064ae9f34c0beb3874d5d Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 22 May 2026 19:23:04 -0600 Subject: [PATCH 08/14] :label: fix(nativewindui): forward searchBar.testID to the search-icon button in LargeTitleHeader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing patch. `LargeTitleHeader` accepts a `searchBar.testID` prop and the comment in CatalogItemsScreen explicitly notes "testID exists in runtime 2.0.5 implementation but absent from published types" — except the published 2.0.3 implementation also doesn't forward it to the actual icon Button on Android/web. The catalog-search Playwright test waits for `getByTestId('catalog:search-btn')` to be visible and times out because the testID is set on a JSX object that's never propagated to the DOM. Adds `testID={props.searchBar.testID}` to the search-icon Button so the testID lands on the rendered element. No-op on iOS (separate platform file) and prod parity since the underlying NativeWindUI 2.0.5 has this fix too. --- bun.lock | 2 +- patches/@packrat-ai%2Fnativewindui@2.0.3.patch | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index e4ff1e67e4..adac6c09f9 100644 --- a/bun.lock +++ b/bun.lock @@ -643,7 +643,7 @@ "name": "@packrat/ui", "version": "2.0.25", "dependencies": { - "@packrat-ai/nativewindui": "^2.0.5", + "@packrat-ai/nativewindui": "^2.0.6", }, }, "packages/units": { diff --git a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch index d4837e9502..515c4c1ac1 100644 --- a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch +++ b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch @@ -17,7 +17,7 @@ index 786b62c4a216c360beb193b96092186319a634cb..741aefba1ddf080c103cfca27b14b10c import type MaterialIcons from '@expo/vector-icons/MaterialIcons'; import type { SymbolViewProps } from 'expo-symbols'; diff --git a/src/components/LargeTitleHeader/LargeTitleHeader.tsx b/src/components/LargeTitleHeader/LargeTitleHeader.tsx -index 95c66bbdece307542084c2e510c847543134f63e..d9bf7337f45a9b49f69863a16a24672363a3b8af 100644 +index 95c66bbdece307542084c2e510c847543134f63e..1d51bc059291f852567c8123ee93ef5f433a9dfb 100644 --- a/src/components/LargeTitleHeader/LargeTitleHeader.tsx +++ b/src/components/LargeTitleHeader/LargeTitleHeader.tsx @@ -1,3 +1,4 @@ @@ -25,3 +25,11 @@ index 95c66bbdece307542084c2e510c847543134f63e..d9bf7337f45a9b49f69863a16a246723 import { useRoute } from '@react-navigation/native'; import { useAugmentedRef } from '@rn-primitives/hooks'; import { Portal } from '@rn-primitives/portal'; +@@ -157,6 +158,7 @@ export function LargeTitleHeader(props: LargeTitleHeaderProps) { + + {!!props.searchBar && ( +