From cf19d23d8a376c83659bc5cf5733141ca920c7b9 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 21:00:12 +0100 Subject: [PATCH] fix(e2e): align browser harness with local stack --- .codex/skills/dev-local/SKILL.md | 32 ++++++----- apps/api/src/app.ts | 11 ++++ apps/api/src/config.ts | 22 +++++++ apps/api/src/index.ts | 1 + apps/api/test/app.test.ts | 29 ++++++++++ apps/web/AGENTS.md | 3 +- docs/development/local-stack.md | 12 ++++ docs/testing/strategy.md | 13 +++-- e2e/README.md | 19 +++++-- e2e/helpers/local-postgres.ts | 82 +++++++++++++++++++++++++++ e2e/helpers/local-supabase.ts | 36 ------------ e2e/package.json | 2 +- e2e/specs/admin.spec.ts | 3 +- e2e/specs/mcp.spec.ts | 3 +- e2e/specs/web.spec.ts | 18 ++---- infra/backend/README.md | 5 +- infra/backend/compose.production.yaml | 1 + package-lock.json | 12 ++-- package.json | 2 +- scripts/dev-local.sh | 46 ++++++++++++++- 20 files changed, 267 insertions(+), 85 deletions(-) create mode 100644 e2e/helpers/local-postgres.ts delete mode 100644 e2e/helpers/local-supabase.ts diff --git a/.codex/skills/dev-local/SKILL.md b/.codex/skills/dev-local/SKILL.md index 638d069..336d04b 100644 --- a/.codex/skills/dev-local/SKILL.md +++ b/.codex/skills/dev-local/SKILL.md @@ -9,20 +9,26 @@ Use `scripts/dev-local.sh`; do not manually mix remote and local services. | Service | Port | |---|---:| -| web | 3000 | -| admin | 3001 | -| MCP | 3002 | +| PostgreSQL | 54320 | +| API | 3003 | +| web | 3000 (overridable with `SAUCI_WEB_PORT`) | +| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) | +| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) | | Expo Metro | 8081 | -| Supabase API / DB / Studio | 54321 / 54322 / 54323 | -Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, and -the repository Supabase CLI dependency. +Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, +and the configured hosted Auth credentials where an authenticated client needs them. -- `scripts/dev-local.sh up` — idempotently start infra and services. -- `status`, `logs `, `restart `, `attach` — inspect. -- `reset` — local-only database reset plus deterministic fixtures. -- `ios` / `android` — launch the native app against local Supabase. -- `down`; `down --all` — stop services, optionally infrastructure. +- `scripts/dev-local.sh up`: idempotently start PostgreSQL and services. +- `status`, `logs `, `restart `, `attach`: inspect. +- `reset`: local-only database reset plus deterministic fixtures. +- `e2e`: start or safely reuse the local stack, validate local endpoints, and + run Playwright with launcher-derived URLs and a loopback PostgreSQL fixture. +- `ios` / `android`: launch the native app against the standalone local API and + configured hosted Auth project. +- `down`; `down --all`: stop services, optionally infrastructure. -If a port is occupied, use `status` and inspect its owning process before stopping -anything. If a tmux window exited, read its logs before restarting it. +Hosted Supabase provides Auth only. Local E2E never starts or writes to a +Supabase data plane. If a port is occupied, use `status` and inspect its owning +process before stopping anything. If a tmux window exited, read its logs before +restarting it. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 86d92dd..193dee3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,6 @@ import type { ApiErrorResponse, MeResponse } from '@sauci/shared'; import { Hono } from 'hono'; +import { cors } from 'hono/cors'; import { z } from 'zod'; import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js'; import type { ApiRepository } from './db/repository.js'; @@ -36,6 +37,7 @@ type Variables = { identity: AuthIdentity }; export interface AppDependencies { auth: AuthVerifier; repository: ApiRepository; + corsAllowedOrigins?: string[]; coupleService?: CoupleService; packsRepository?: PacksRepository; answersRepository?: AnswersRepository; @@ -61,6 +63,15 @@ function error(code: string, message: string): ApiErrorResponse { export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> { const app = new Hono<{ Variables: Variables }>(); + const corsAllowedOrigins = new Set(deps.corsAllowedOrigins ?? ['https://sauci.app']); + // The public web redemption form calls the API from a distinct browser origin. + // API authentication remains bearer-token based, so this does not grant cross-origin access to protected data. + app.use('*', cors({ + origin: (origin) => corsAllowedOrigins.has(origin) ? origin : null, + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + })); + app.use('*', async (c, next) => { const startedAt = performance.now(); try { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index b35a5f2..e49c006 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -2,10 +2,30 @@ import { z } from 'zod'; const optionalString = z.preprocess((value) => value === '' ? undefined : value, z.string().min(1).optional()); const optionalUuid = z.preprocess((value) => value === '' ? undefined : value, z.string().uuid().optional()); +const corsOrigins = z.string().default('https://sauci.app').transform((value, ctx) => { + const origins = value.split(',').map((origin) => origin.trim()).filter(Boolean); + if (origins.length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'CORS_ALLOWED_ORIGINS must contain at least one origin' }); + return z.NEVER; + } + for (const origin of origins) { + try { + const parsed = new URL(origin); + if (parsed.origin !== origin || parsed.username || parsed.password || parsed.pathname !== '/') { + throw new Error('not an origin'); + } + } catch { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid CORS origin: ${origin}` }); + return z.NEVER; + } + } + return origins; +}); const baseSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().min(1).max(65535).default(3003), + CORS_ALLOWED_ORIGINS: corsOrigins, DATABASE_URL: z.string().url(), DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10), DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000), @@ -30,6 +50,7 @@ const baseSchema = z.object({ export interface AppConfig { nodeEnv: 'development' | 'test' | 'production'; port: number; + corsAllowedOrigins: string[]; databaseUrl: string; databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }; authIssuer: string; @@ -84,6 +105,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { return { nodeEnv: parsed.NODE_ENV, port: parsed.PORT, + corsAllowedOrigins: parsed.CORS_ALLOWED_ORIGINS, databaseUrl: parsed.DATABASE_URL, databasePool: { max: parsed.DATABASE_POOL_MAX, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 66029d9..e7e5b26 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -65,6 +65,7 @@ const adminRepository = new PostgresAdminRepository(pool, { const app = createApp({ auth: authVerifier, repository, + corsAllowedOrigins: config.corsAllowedOrigins, coupleService: new CoupleService(coupleRepository), packsRepository, answersRepository, diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index f19f1a4..cbe108c 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -81,6 +81,35 @@ describe('Sauci API', () => { expect(repository.ready).toHaveBeenCalledOnce(); }); + it('allows browser preflight requests only from configured web origins', async () => { + const app = createApp({ + auth: verifier(), + repository: new MemoryRepository(), + corsAllowedOrigins: ['http://127.0.0.1:3010', 'https://sauci.app'], + }); + const response = await app.request('/public/v1/redemptions', { + method: 'OPTIONS', + headers: { + origin: 'http://127.0.0.1:3010', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type', + }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-origin')).toBe('http://127.0.0.1:3010'); + expect(response.headers.get('access-control-allow-methods')).toContain('POST'); + + const rejected = await app.request('/public/v1/redemptions', { + method: 'OPTIONS', + headers: { + origin: 'https://unapproved.example', + 'access-control-request-method': 'POST', + }, + }); + expect(rejected.headers.get('access-control-allow-origin')).toBeNull(); + }); + it('fails closed when the bearer token is missing or invalid', async () => { const app = createApp({ auth: verifier(), repository: new MemoryRepository() }); expect((await app.request('/v1/me')).status).toBe(401); diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index eb496a0..054c455 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -6,5 +6,6 @@ Next.js App Router marketing, legal, and redemption UI. - Preserve PostHog provider/page-view integration unless the task changes it. - Keep metadata current for public pages. - Never expose server credentials through `NEXT_PUBLIC_*` variables. -- Public redemption must use sandbox/local Supabase during automated tests. +- Public redemption E2E must use the loopback standalone API and PostgreSQL + fixture harness, never the retired Supabase data plane. - Verify lint, typecheck, build, and the relevant Playwright flow. diff --git a/docs/development/local-stack.md b/docs/development/local-stack.md index 684987a..0a161af 100644 --- a/docs/development/local-stack.md +++ b/docs/development/local-stack.md @@ -115,3 +115,15 @@ without credentials. `e2e/maestro/login-smoke.yaml` is the authenticated standalone-API flow and must fail closed unless the non-production fixture above is explicitly supplied. Neither local nor E2E runs may use the production Auth project, production subscriber, or production application database. + +Run browser E2E through the same launcher: + +```bash +npm run verify:e2e +``` + +It starts the stack when needed, waits for API, web, admin, and MCP endpoints, +and derives Playwright URLs from the configured `SAUCI_*_PORT` values. Set +`SAUCI_E2E_REUSE=true` only to reuse a healthy stack managed with the same +configuration. Browser product-data fixtures are direct loopback PostgreSQL +fixtures that prove the standalone API result; they never use local Supabase. diff --git a/docs/testing/strategy.md b/docs/testing/strategy.md index 5dafba2..f201e2b 100644 --- a/docs/testing/strategy.md +++ b/docs/testing/strategy.md @@ -8,8 +8,9 @@ is proved through the real stack. - `verify:fast`: invariant lint, workspace lint/typecheck, and unit tests. - `verify:full`: fast gate plus builds and standalone API/PostgreSQL integration tests against a loopback database. -- `verify:e2e`: Playwright browser flows and native Maestro flows against an - already-running local stack. +- `verify:e2e`: starts or safely reuses the documented standalone local stack, + then runs Playwright browser flows. Native Maestro flows remain an explicit + separate check against that stack. - `verify`: full plus E2E; this is the pre-PR target. The required CI checks also run instrumented coverage for the API and mobile @@ -35,6 +36,8 @@ a pre-promotion check because it requires running services and test identities. - Cross-application browser tests live in `e2e`. - Native black-box flows live in `e2e/maestro`. -E2E product-data endpoints must be local or staging, never production. Local -database tests refuse non-loopback URLs. Authenticated staging acceptance uses -the designated non-production hosted Auth project and disposable fixture users. +E2E product-data endpoints and fixture databases must be loopback, never +production or staging. The browser redemption flow uses a disposable local +PostgreSQL fixture and verifies the API's resulting state, rather than the +retired Supabase data plane. Authenticated native acceptance uses the designated +non-production hosted Auth project and disposable fixture users. diff --git a/e2e/README.md b/e2e/README.md index e4f4c79..0c22627 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,9 +1,20 @@ # System E2E -Tests assume `scripts/dev-local.sh up` is already running. Execute with -`npm run verify:e2e`; application data must use the loopback standalone API and -PostgreSQL. Playwright retains traces, screenshots, and videos on failure under -ignored output directories. +Execute with `npm run verify:e2e`. The command starts the documented standalone +local stack, waits for API, web, admin, and MCP health, and supplies Playwright +with the ports selected by `scripts/dev-local.sh`. Product-data fixtures use only +the loopback standalone PostgreSQL database and assert the visible web result +plus the resulting database state. It never starts or writes to the retired +Supabase data plane. + +To reuse a stack that the same local-stack configuration already manages, set +`SAUCI_E2E_REUSE=true`. The command still requires the approved local/non-production +Auth URL, loopback PostgreSQL, and all four local services to be healthy. Keep +the same `SAUCI_*_PORT` overrides that were used to start that stack; do not pass +ad-hoc `WEB_URL`, `ADMIN_URL`, or `MCP_URL` values. + +Playwright retains traces, screenshots, and videos on failure under ignored +output directories. Native flows live under `maestro/` and run with `npm run test:mobile:e2e` after an iOS or Android build is installed. `login-screen-smoke.yaml` is credential-free. diff --git a/e2e/helpers/local-postgres.ts b/e2e/helpers/local-postgres.ts new file mode 100644 index 0000000..e86c9cf --- /dev/null +++ b/e2e/helpers/local-postgres.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; + +function localDatabaseUrl(): string { + const raw = process.env.E2E_DATABASE_URL; + if (!raw) throw new Error('Run E2E through npm run verify:e2e'); + + const url = new URL(raw); + if (url.protocol !== 'postgresql:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) { + throw new Error(`E2E refuses non-loopback PostgreSQL: ${url.origin}`); + } + return raw; +} + +export interface RedemptionFixture { + email: string; + code: string; + userId: string; + codeId: string; + assertRedeemed(): Promise; + cleanup(): Promise; +} + +export async function createRedemptionFixture(): Promise { + const pool = new Pool({ connectionString: localDatabaseUrl() }); + const nonce = randomUUID(); + const userId = randomUUID(); + const email = `redeem-${nonce}@example.test`; + const code = `E2E-${nonce}`.toUpperCase(); + + let codeId: string | undefined; + try { + const codeResult = await pool.query<{ id: string }>( + `insert into redemption_codes (code, description, max_uses, is_active, expires_at) + values ($1, 'Playwright loopback fixture', 1, true, now() + interval '1 hour') + returning id`, + [code], + ); + codeId = codeResult.rows[0]?.id; + if (!codeId) throw new Error('Failed to create redemption code fixture'); + + await pool.query( + `insert into profiles (id, email, name) + values ($1, $2, 'Playwright Redemption Fixture')`, + [userId, email], + ); + } catch (cause) { + if (codeId) await pool.query('delete from redemption_codes where id = $1', [codeId]); + await pool.end(); + throw cause; + } + + return { + email, + code, + userId, + codeId, + async assertRedeemed() { + const profile = await pool.query<{ is_premium: boolean }>( + 'select is_premium from profiles where id = $1', + [userId], + ); + if (profile.rows[0]?.is_premium !== true) { + throw new Error('Redemption fixture profile was not marked premium'); + } + + const redemptions = await pool.query<{ count: number }>( + `select count(*)::int as count from code_redemptions + where code_id = $1 and user_id = $2`, + [codeId, userId], + ); + if (redemptions.rows[0]?.count !== 1) { + throw new Error('Redemption fixture did not record exactly one redemption'); + } + }, + async cleanup() { + await pool.query('delete from redemption_codes where id = $1', [codeId]); + await pool.query('delete from profiles where id = $1', [userId]); + await pool.end(); + }, + }; +} diff --git a/e2e/helpers/local-supabase.ts b/e2e/helpers/local-supabase.ts deleted file mode 100644 index a7b836c..0000000 --- a/e2e/helpers/local-supabase.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; - -export function localSupabase() { - const url = process.env.SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !serviceKey) throw new Error('Run E2E through npm run verify:e2e'); - const host = new URL(url).hostname; - if (host !== '127.0.0.1' && host !== 'localhost') { - throw new Error(`E2E refuses non-local Supabase host: ${host}`); - } - return createClient(url, serviceKey, { - auth: { autoRefreshToken: false, persistSession: false }, - }); -} - -export async function createRedemptionFixture() { - const client = localSupabase(); - const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const email = `redeem-${nonce}@example.test`; - const code = `E2E-${nonce}`.toUpperCase(); - const { data: created, error: userError } = await client.auth.admin.createUser({ - email, - password: 'Sauci-e2e-only-2026!', - email_confirm: true, - }); - if (userError || !created.user) throw userError ?? new Error('User creation failed'); - const { data: redemptionCode, error: codeError } = await client.from('redemption_codes').insert({ - code, - description: 'Playwright local fixture', - max_uses: 1, - is_active: true, - expires_at: new Date(Date.now() + 3_600_000).toISOString(), - }).select().single(); - if (codeError) throw codeError; - return { client, email, code, userId: created.user.id, codeId: redemptionCode.id }; -} diff --git a/e2e/package.json b/e2e/package.json index 5b53f96..7abecaf 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -11,8 +11,8 @@ }, "devDependencies": { "@playwright/test": "^1.50.1", - "@supabase/supabase-js": "^2.49.1", "@types/node": "^20.17.19", + "pg": "^8.17.2", "typescript": "^5.7.3" } } diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts index d106162..e302bca 100644 --- a/e2e/specs/admin.spec.ts +++ b/e2e/specs/admin.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; -const admin = process.env.ADMIN_URL ?? 'http://127.0.0.1:3001'; +const admin = process.env.ADMIN_URL; +if (!admin) throw new Error('Run E2E through npm run verify:e2e'); test('unauthenticated admin access reaches the real login boundary', async ({ page }) => { await page.goto(admin); diff --git a/e2e/specs/mcp.spec.ts b/e2e/specs/mcp.spec.ts index b5d0f07..3e80d70 100644 --- a/e2e/specs/mcp.spec.ts +++ b/e2e/specs/mcp.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; -const mcp = process.env.MCP_URL ?? 'http://127.0.0.1:3002'; +const mcp = process.env.MCP_URL; +if (!mcp) throw new Error('Run E2E through npm run verify:e2e'); test('MCP health is available while tool access requires auth', async ({ request }) => { const health = await request.get(`${mcp}/health`); diff --git a/e2e/specs/web.spec.ts b/e2e/specs/web.spec.ts index a015036..0a7c565 100644 --- a/e2e/specs/web.spec.ts +++ b/e2e/specs/web.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from '@playwright/test'; -import { createRedemptionFixture } from '../helpers/local-supabase'; +import { createRedemptionFixture } from '../helpers/local-postgres'; -const web = process.env.WEB_URL ?? 'http://127.0.0.1:3000'; +const web = process.env.WEB_URL; +if (!web) throw new Error('Run E2E through npm run verify:e2e'); test('marketing home renders the primary product journey', async ({ page }) => { await page.goto(web); @@ -27,17 +28,8 @@ test('redemption updates server state and visible product state', async ({ page await expect(page.getByRole('heading', { name: 'Code Redeemed!' })).toBeVisible(); await expect(page.getByText(fixture.email)).toBeVisible(); - const { data: profile, error: profileError } = await fixture.client - .from('profiles').select('is_premium').eq('id', fixture.userId).single(); - expect(profileError).toBeNull(); - expect(profile?.is_premium).toBe(true); - const { count, error: redemptionError } = await fixture.client - .from('code_redemptions').select('*', { count: 'exact', head: true }) - .eq('code_id', fixture.codeId).eq('user_id', fixture.userId); - expect(redemptionError).toBeNull(); - expect(count).toBe(1); + await expect(fixture.assertRedeemed()).resolves.toBeUndefined(); } finally { - await fixture.client.auth.admin.deleteUser(fixture.userId); - await fixture.client.from('redemption_codes').delete().eq('id', fixture.codeId); + await fixture.cleanup(); } }); diff --git a/infra/backend/README.md b/infra/backend/README.md index 9bd3568..23ba6cd 100644 --- a/infra/backend/README.md +++ b/infra/backend/README.md @@ -87,7 +87,10 @@ Before the first deployment, provide: 5. A rollback and restore point before any migrated production data is imported. 6. A separately generated `MEDIA_SIGNING_SECRET` (at least 32 characters) and the HTTPS API origin as `MEDIA_PUBLIC_BASE_URL`. -7. A non-production hosted Auth project and disposable, onboarding-complete test +7. The public web origin is fixed to `https://sauci.app` by + `CORS_ALLOWED_ORIGINS` in the Compose contract. Do not broaden it without a + reviewed browser client requirement. +8. A non-production hosted Auth project and disposable, onboarding-complete test identities mapped into the staging database for authenticated mobile acceptance. Blob deletion is retryable. Database cascades and avatar replacement enqueue diff --git a/infra/backend/compose.production.yaml b/infra/backend/compose.production.yaml index eee0a83..bc29ed9 100644 --- a/infra/backend/compose.production.yaml +++ b/infra/backend/compose.production.yaml @@ -8,6 +8,7 @@ x-common-environment: &common-environment x-api-environment: &api-environment <<: *common-environment PORT: 3003 + CORS_ALLOWED_ORIGINS: https://sauci.app DATABASE_POOL_MAX: ${API_DATABASE_POOL_MAX:-10} DATABASE_POOL_IDLE_TIMEOUT_MS: ${DATABASE_POOL_IDLE_TIMEOUT_MS:-30000} DATABASE_POOL_CONNECTION_TIMEOUT_MS: ${DATABASE_POOL_CONNECTION_TIMEOUT_MS:-10000} diff --git a/package-lock.json b/package-lock.json index c7ff4e7..8060f29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -772,12 +772,12 @@ "e2e": { "name": "@sauci/e2e", "version": "1.0.0", - "devDependencies": { - "@playwright/test": "^1.50.1", - "@supabase/supabase-js": "^2.49.1", - "@types/node": "^20.17.19", - "typescript": "^5.7.3" - } + "devDependencies": { + "@playwright/test": "^1.50.1", + "@types/node": "^20.17.19", + "pg": "^8.17.2", + "typescript": "^5.7.3" + } }, "e2e/node_modules/@types/node": { "version": "20.19.43", diff --git a/package.json b/package.json index 6a2619a..ee0e366 100755 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:mobile:e2e": "maestro test e2e/maestro", "verify:fast": "npm run lint && npm run typecheck && npm test", "verify:full": "npm run verify:fast && npm run build && npm run test:api:integration", - "verify:e2e": "scripts/with-local-supabase-env.sh tests npm run test -w @sauci/e2e", + "verify:e2e": "scripts/dev-local.sh e2e", "verify": "npm run verify:full && npm run verify:e2e", "db:start": "npx supabase start --workdir apps", "db:stop": "npx supabase stop --workdir apps", diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh index a9b8926..c3408e3 100755 --- a/scripts/dev-local.sh +++ b/scripts/dev-local.sh @@ -107,7 +107,7 @@ LOCAL_ADMIN_SERVICE_TOKEN="${ADMIN_API_SERVICE_TOKEN:-local-sauci-admin-service- LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}" SERVERS=( - "api|PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api" + "api|PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL CORS_ALLOWED_ORIGINS=http://127.0.0.1:$WEB_PORT MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api" "worker|DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api" "web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port $WEB_PORT" "admin|env VITE_API_URL=/ SAUCI_ADMIN_API_PROXY=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port $ADMIN_PORT" @@ -161,6 +161,13 @@ assert_local_database() { [ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320." } +assert_loopback_database() { + case "$LOCAL_DATABASE_URL" in + postgresql://*@127.0.0.1:*/*|postgresql://*@localhost:*/*) ;; + *) die "Refusing E2E fixture operations against non-loopback URL: $LOCAL_DATABASE_URL" ;; + esac +} + assert_allowed_auth() { local verdict verdict="$(node - "$AUTH_URL" <<'NODE' @@ -569,6 +576,40 @@ cmd_seed() { ok "Local migrations applied and configured fixtures created" } +wait_for_e2e_service() { + local name="$1" url="$2" attempts=0 + command -v curl >/dev/null 2>&1 || die "curl is required for E2E health checks." + until curl --fail --silent --show-error "$url" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + [ "$attempts" -lt 60 ] || die "Timed out waiting for local $name at $url" + sleep 1 + done +} + +cmd_e2e() { + if [ "${SAUCI_E2E_REUSE:-false}" = "true" ]; then + preflight + assert_loopback_database + [ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required when reusing the local stack." + assert_allowed_auth + else + cmd_up + fi + + wait_for_e2e_service "API" "$LOCAL_API_URL/health/live" + wait_for_e2e_service "web" "http://127.0.0.1:$WEB_PORT/" + wait_for_e2e_service "admin" "http://127.0.0.1:$ADMIN_PORT/" + wait_for_e2e_service "MCP" "http://127.0.0.1:$MCP_PORT/health" + + (cd "$ROOT" && env \ + E2E_DATABASE_URL="$LOCAL_DATABASE_URL" \ + E2E_API_URL="$LOCAL_API_URL" \ + WEB_URL="http://127.0.0.1:$WEB_PORT" \ + ADMIN_URL="http://127.0.0.1:$ADMIN_PORT" \ + MCP_URL="http://127.0.0.1:$MCP_PORT" \ + npm run test -w @sauci/e2e) +} + cmd_native() { local platform="$1" preflight @@ -591,9 +632,10 @@ case "${1:-up}" in attach) tmux attach -t "$SESSION" ;; reset) cmd_reset ;; seed) cmd_seed ;; + e2e) cmd_e2e ;; ios|android) cmd_native "$1" ;; help|-h|--help) - echo "usage: scripts/dev-local.sh up|down [--all]|status|logs |restart |attach|reset|seed|ios|android" + echo "usage: scripts/dev-local.sh up|down [--all]|status|logs |restart |attach|reset|seed|e2e|ios|android" ;; *) die "unknown command '$1'" ;; esac