Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
9eb14aa
:whale: chore(api): add local-neon-http-proxy on :4444 to test stack
andrew-bierman May 23, 2026
b732454
:bug: fix(api): wire local dev to db.localtest.me proxy + CORS the au…
andrew-bierman May 23, 2026
ddf005f
:closed_lock_with_key: fix(api/auth): trust localhost origins when AP…
andrew-bierman May 23, 2026
a490672
:seedling: fix(api/seed): create account row alongside users so Bette…
andrew-bierman May 23, 2026
e6fe8b0
:seedling: feat(api/seed): add catalog seed for E2E (10 hand-picked i…
andrew-bierman May 23, 2026
19808f1
:white_check_mark: test(e2e): chrome channel + headed-local + paralle…
andrew-bierman May 23, 2026
2b684d0
:adhesive_bandage: test(e2e): settings regex tolerates "(Dev)" build …
andrew-bierman May 23, 2026
a2079f8
:label: fix(nativewindui): forward searchBar.testID to the search-ico…
andrew-bierman May 23, 2026
acd5cb4
:bug: fix(expo/ai-chat): resolve auth token at send-time so first sen…
andrew-bierman May 28, 2026
194b1da
:closed_lock_with_key: fix(api/auth): use wildcard 'http://localhost:…
andrew-bierman May 28, 2026
b3696b2
:wrench: chore(api): make compose host ports overrideable via env vars
andrew-bierman May 28, 2026
44ec618
:lock: test(e2e): default to headless everywhere; opt into headed wit…
andrew-bierman May 28, 2026
96c3783
:wrench: chore(env): use typed env shims for OPENAI_API_KEY + NEON_LO…
andrew-bierman May 28, 2026
eb4a368
:label: fix(expo/ai-chat): widen headers function return to Record<st…
andrew-bierman May 30, 2026
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
20 changes: 15 additions & 5 deletions apps/expo/app/(app)/ai-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,14 @@ export default function AIChat() {
const locationRef = React.useRef(context.location);
locationRef.current = context.location;

const { data: _authSession } = authClient.useSession();
const token = _authSession?.session?.token ?? null;
// We deliberately don't read `useSession()` data into the transport
// closure. On first render `data?.session?.token` is null, the transport
// builds with `Authorization: Bearer null`, and the very first send hits
// /api/chat unauthenticated — the API responds 401 and useChat shows the
// generic "something went wrong" UI. Reading the token lazily via
// `authClient.getSession()` at each request (below) avoids that race
// entirely; getSession is cached after the first call so this is cheap.
authClient.useSession();
const [input, setInput] = React.useState('');
const [lastUserMessage, setLastUserMessage] = React.useState('');
const [previousMessages, setPreviousMessages] = React.useState<UIMessage[]>([]);
Expand Down Expand Up @@ -133,8 +139,12 @@ export default function AIChat() {
return new DefaultChatTransport({
fetch: expoFetch as unknown as typeof globalThis.fetch,
api: `${clientEnvs.EXPO_PUBLIC_API_URL}/api/chat`,
headers: {
Authorization: `Bearer ${token}`,
headers: async () => {
const { data } = await authClient.getSession();
const token = data?.session?.token ?? '';
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
body: () => ({
contextType: contextRef.current.contextType,
Expand All @@ -144,7 +154,7 @@ export default function AIChat() {
date: new Date().toLocaleString(),
}),
});
}, [aiMode, isLocalReady, token, tools]);
}, [aiMode, isLocalReady, tools]);

const { messages, setMessages, error, sendMessage, stop, status } = useChat({
transport,
Expand Down
29 changes: 25 additions & 4 deletions apps/expo/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,44 @@ 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 everywhere. Opt into a visible browser with
// `PWHEADED=1` — never have the run pop windows on the dev's desktop
// unless explicitly requested.
headless: process.env.PWHEADED !== '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',
],
},
},
},
],
});
3 changes: 2 additions & 1 deletion apps/expo/playwright/tests/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down
7 changes: 6 additions & 1 deletion apps/expo/playwright/tests/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export default async function setup() {

fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true });

const browser = await chromium.launch();
// Headless by default; opt into a visible browser with PWHEADED=1.
const browser = await chromium.launch({
channel: 'chrome',
headless: process.env.PWHEADED !== '1',
args: ['--incognito', '--no-default-browser-check', '--no-first-run', '--password-store=basic'],
});
Comment on lines +19 to +23

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 | 🟡 Minor | ⚡ Quick win

Ensure browser cleanup on setup failures.

If any step throws after chromium.launch (Line 19), browser.close() (Line 65) is skipped. Wrap setup steps in try/finally so the browser always closes.

Proposed fix
-  const browser = await chromium.launch({
+  const browser = await chromium.launch({
     channel: 'chrome',
     headless: process.env.PWHEADED !== '1',
     args: ['--incognito', '--no-default-browser-check', '--no-first-run', '--password-store=basic'],
   });
-  const context = await browser.newContext();
-  const page = await context.newPage();
+  try {
+    const context = await browser.newContext();
+    const page = await context.newPage();
 
-  // Start from the auth entry screen, then click through to login
-  await page.goto(`${BASE_URL}/auth`, { waitUntil: 'load' });
+    // Start from the auth entry screen, then click through to login
+    await page.goto(`${BASE_URL}/auth`, { waitUntil: 'load' });
 
-  // ... existing setup steps ...
+    // ... existing setup steps ...
 
-  await context.storageState({ path: AUTH_STATE_PATH });
-  console.log(`[globalSetup] Logged in as ${email}`);
-
-  await browser.close();
+    await context.storageState({ path: AUTH_STATE_PATH });
+    console.log(`[globalSetup] Logged in as ${email}`);
+  } finally {
+    await browser.close();
+  }

Also applies to: 65-65

🤖 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/playwright/tests/globalSetup.ts` around lines 19 - 23, The setup
creates a Playwright browser via chromium.launch but doesn’t guarantee
browser.close() runs if subsequent setup steps throw; wrap the code that uses
the returned browser in a try/finally (use the existing browser variable from
chromium.launch) and call await browser.close() in the finally block so the
browser is always closed, rethrow or propagate any caught error after cleanup;
apply the same try/finally pattern around any other setup sequence that opens
the browser and relies on browser.close() (the block currently calling
browser.close()).

const context = await browser.newContext();
const page = await context.newPage();

Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 20 additions & 4 deletions packages/api/docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ services:
POSTGRES_PASSWORD: test_password
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5433:5432"
- "${POSTGRES_TEST_HOST_PORT:-5433}:5432"
volumes:
- postgres_test_data:/var/lib/postgresql/data
healthcheck:
Expand All @@ -23,16 +23,32 @@ 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:
APPEND_PORT: ""
ALLOW_ADDR_REGEX: ".*"
LOG_CONN_INFO: "true"
ports:
- "5434:80"
- "${WSPROXY_HOST_PORT:-5434}:80"
depends_on:
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:
- "${NEON_PROXY_HOST_PORT:-4444}:4444"
Comment on lines +46 to +51
depends_on:
postgres-test:
condition: service_healthy
Expand Down
9 changes: 8 additions & 1 deletion packages/api/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,14 @@ export async function getAuth(env: ValidatedEnv): Promise<any> {
storage: 'secondary-storage',
},

trustedOrigins: [env.BETTER_AUTH_URL, 'packrat://'],
trustedOrigins: [
env.BETTER_AUTH_URL,
'packrat://',
// Local web dev — accept any localhost port so parallel agents on
// bumped ports (e.g. 18082) don't need an allowlist update. Gated on
// the API URL pointing at localhost so prod never widens trust.
...(env.BETTER_AUTH_URL.startsWith('http://localhost') ? ['http://localhost:*'] : []),
],
});

authCache.set(env as object, auth);
Expand Down
10 changes: 9 additions & 1 deletion packages/api/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading