fix(e2e): align browser harness with local stack

This commit is contained in:
jjbrunton committed 2026-08-30 21:00:12 +01:00
1 parent db5980c9bd
commit cf19d23d8a
20 files changed
+267 -85

No files matched your search

+19 -13
View File
@@ -9,20 +9,26 @@ Use `scripts/dev-local.sh`; do not manually mix remote and local services.
| Service | Port | | Service | Port |
|---|---:| |---|---:|
| web | 3000 | | PostgreSQL | 54320 |
| admin | 3001 | | API | 3003 |
| MCP | 3002 | | web | 3000 (overridable with `SAUCI_WEB_PORT`) |
| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) |
| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) |
| Expo Metro | 8081 | | Expo Metro | 8081 |
| Supabase API / DB / Studio | 54321 / 54322 / 54323 |
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, and Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime,
the repository Supabase CLI dependency. and the configured hosted Auth credentials where an authenticated client needs them.
- `scripts/dev-local.sh up` — idempotently start infra and services. - `scripts/dev-local.sh up`: idempotently start PostgreSQL and services.
- `status`, `logs <service>`, `restart <service>`, `attach` — inspect. - `status`, `logs <service>`, `restart <service>`, `attach`: inspect.
- `reset` — local-only database reset plus deterministic fixtures. - `reset`: local-only database reset plus deterministic fixtures.
- `ios` / `android` — launch the native app against local Supabase. - `e2e`: start or safely reuse the local stack, validate local endpoints, and
- `down`; `down --all` — stop services, optionally infrastructure. 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 Hosted Supabase provides Auth only. Local E2E never starts or writes to a
anything. If a tmux window exited, read its logs before restarting it. 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.
+11
View File
@@ -1,5 +1,6 @@
import type { ApiErrorResponse, MeResponse } from '@sauci/shared'; import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { z } from 'zod'; import { z } from 'zod';
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js'; import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
import type { ApiRepository } from './db/repository.js'; import type { ApiRepository } from './db/repository.js';
@@ -36,6 +37,7 @@ type Variables = { identity: AuthIdentity };
export interface AppDependencies { export interface AppDependencies {
auth: AuthVerifier; auth: AuthVerifier;
repository: ApiRepository; repository: ApiRepository;
corsAllowedOrigins?: string[];
coupleService?: CoupleService; coupleService?: CoupleService;
packsRepository?: PacksRepository; packsRepository?: PacksRepository;
answersRepository?: AnswersRepository; answersRepository?: AnswersRepository;
@@ -61,6 +63,15 @@ function error(code: string, message: string): ApiErrorResponse {
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> { export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
const app = new 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) => { app.use('*', async (c, next) => {
const startedAt = performance.now(); const startedAt = performance.now();
try { try {
+22
View File
@@ -2,10 +2,30 @@ import { z } from 'zod';
const optionalString = z.preprocess((value) => value === '' ? undefined : value, z.string().min(1).optional()); 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 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({ const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3003), PORT: z.coerce.number().int().min(1).max(65535).default(3003),
CORS_ALLOWED_ORIGINS: corsOrigins,
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10), 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), 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 { export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production'; nodeEnv: 'development' | 'test' | 'production';
port: number; port: number;
corsAllowedOrigins: string[];
databaseUrl: string; databaseUrl: string;
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }; databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
authIssuer: string; authIssuer: string;
@@ -84,6 +105,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return { return {
nodeEnv: parsed.NODE_ENV, nodeEnv: parsed.NODE_ENV,
port: parsed.PORT, port: parsed.PORT,
corsAllowedOrigins: parsed.CORS_ALLOWED_ORIGINS,
databaseUrl: parsed.DATABASE_URL, databaseUrl: parsed.DATABASE_URL,
databasePool: { databasePool: {
max: parsed.DATABASE_POOL_MAX, max: parsed.DATABASE_POOL_MAX,
+1
View File
@@ -65,6 +65,7 @@ const adminRepository = new PostgresAdminRepository(pool, {
const app = createApp({ const app = createApp({
auth: authVerifier, auth: authVerifier,
repository, repository,
corsAllowedOrigins: config.corsAllowedOrigins,
coupleService: new CoupleService(coupleRepository), coupleService: new CoupleService(coupleRepository),
packsRepository, packsRepository,
answersRepository, answersRepository,
+29
View File
@@ -81,6 +81,35 @@ describe('Sauci API', () => {
expect(repository.ready).toHaveBeenCalledOnce(); 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 () => { it('fails closed when the bearer token is missing or invalid', async () => {
const app = createApp({ auth: verifier(), repository: new MemoryRepository() }); const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
expect((await app.request('/v1/me')).status).toBe(401); expect((await app.request('/v1/me')).status).toBe(401);
+2 -1
View File
@@ -6,5 +6,6 @@ Next.js App Router marketing, legal, and redemption UI.
- Preserve PostHog provider/page-view integration unless the task changes it. - Preserve PostHog provider/page-view integration unless the task changes it.
- Keep metadata current for public pages. - Keep metadata current for public pages.
- Never expose server credentials through `NEXT_PUBLIC_*` variables. - 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. - Verify lint, typecheck, build, and the relevant Playwright flow.
+12
View File
@@ -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 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 is explicitly supplied. Neither local nor E2E runs may use the production Auth
project, production subscriber, or production application database. 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.
+8 -5
View File
@@ -8,8 +8,9 @@ is proved through the real stack.
- `verify:fast`: invariant lint, workspace lint/typecheck, and unit tests. - `verify:fast`: invariant lint, workspace lint/typecheck, and unit tests.
- `verify:full`: fast gate plus builds and standalone API/PostgreSQL integration - `verify:full`: fast gate plus builds and standalone API/PostgreSQL integration
tests against a loopback database. tests against a loopback database.
- `verify:e2e`: Playwright browser flows and native Maestro flows against an - `verify:e2e`: starts or safely reuses the documented standalone local stack,
already-running 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. - `verify`: full plus E2E; this is the pre-PR target.
The required CI checks also run instrumented coverage for the API and mobile 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`. - Cross-application browser tests live in `e2e`.
- Native black-box flows live in `e2e/maestro`. - Native black-box flows live in `e2e/maestro`.
E2E product-data endpoints must be local or staging, never production. Local E2E product-data endpoints and fixture databases must be loopback, never
database tests refuse non-loopback URLs. Authenticated staging acceptance uses production or staging. The browser redemption flow uses a disposable local
the designated non-production hosted Auth project and disposable fixture users. 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.
+15 -4
View File
@@ -1,9 +1,20 @@
# System E2E # System E2E
Tests assume `scripts/dev-local.sh up` is already running. Execute with Execute with `npm run verify:e2e`. The command starts the documented standalone
`npm run verify:e2e`; application data must use the loopback standalone API and local stack, waits for API, web, admin, and MCP health, and supplies Playwright
PostgreSQL. Playwright retains traces, screenshots, and videos on failure under with the ports selected by `scripts/dev-local.sh`. Product-data fixtures use only
ignored output directories. 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 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. iOS or Android build is installed. `login-screen-smoke.yaml` is credential-free.
+82
View File
@@ -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<void>;
cleanup(): Promise<void>;
}
export async function createRedemptionFixture(): Promise<RedemptionFixture> {
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();
},
};
}
-36
View File
@@ -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 };
}
+1 -1
View File
@@ -11,8 +11,8 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@supabase/supabase-js": "^2.49.1",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"pg": "^8.17.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'; 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 }) => { test('unauthenticated admin access reaches the real login boundary', async ({ page }) => {
await page.goto(admin); await page.goto(admin);
+2 -1
View File
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'; 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 }) => { test('MCP health is available while tool access requires auth', async ({ request }) => {
const health = await request.get(`${mcp}/health`); const health = await request.get(`${mcp}/health`);
+5 -13
View File
@@ -1,7 +1,8 @@
import { expect, test } from '@playwright/test'; 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 }) => { test('marketing home renders the primary product journey', async ({ page }) => {
await page.goto(web); 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.getByRole('heading', { name: 'Code Redeemed!' })).toBeVisible();
await expect(page.getByText(fixture.email)).toBeVisible(); await expect(page.getByText(fixture.email)).toBeVisible();
const { data: profile, error: profileError } = await fixture.client await expect(fixture.assertRedeemed()).resolves.toBeUndefined();
.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);
} finally { } finally {
await fixture.client.auth.admin.deleteUser(fixture.userId); await fixture.cleanup();
await fixture.client.from('redemption_codes').delete().eq('id', fixture.codeId);
} }
}); });
+4 -1
View File
@@ -87,7 +87,10 @@ Before the first deployment, provide:
5. A rollback and restore point before any migrated production data is imported. 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 6. A separately generated `MEDIA_SIGNING_SECRET` (at least 32 characters) and
the HTTPS API origin as `MEDIA_PUBLIC_BASE_URL`. 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. identities mapped into the staging database for authenticated mobile acceptance.
Blob deletion is retryable. Database cascades and avatar replacement enqueue Blob deletion is retryable. Database cascades and avatar replacement enqueue
+1
View File
@@ -8,6 +8,7 @@ x-common-environment: &common-environment
x-api-environment: &api-environment x-api-environment: &api-environment
<<: *common-environment <<: *common-environment
PORT: 3003 PORT: 3003
CORS_ALLOWED_ORIGINS: https://sauci.app
DATABASE_POOL_MAX: ${API_DATABASE_POOL_MAX:-10} DATABASE_POOL_MAX: ${API_DATABASE_POOL_MAX:-10}
DATABASE_POOL_IDLE_TIMEOUT_MS: ${DATABASE_POOL_IDLE_TIMEOUT_MS:-30000} DATABASE_POOL_IDLE_TIMEOUT_MS: ${DATABASE_POOL_IDLE_TIMEOUT_MS:-30000}
DATABASE_POOL_CONNECTION_TIMEOUT_MS: ${DATABASE_POOL_CONNECTION_TIMEOUT_MS:-10000} DATABASE_POOL_CONNECTION_TIMEOUT_MS: ${DATABASE_POOL_CONNECTION_TIMEOUT_MS:-10000}
+6 -6
View File
@@ -772,12 +772,12 @@
"e2e": { "e2e": {
"name": "@sauci/e2e", "name": "@sauci/e2e",
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@supabase/supabase-js": "^2.49.1", "@types/node": "^20.17.19",
"@types/node": "^20.17.19", "pg": "^8.17.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
}, },
"e2e/node_modules/@types/node": { "e2e/node_modules/@types/node": {
"version": "20.19.43", "version": "20.19.43",
+1 -1
View File
@@ -25,7 +25,7 @@
"test:mobile:e2e": "maestro test e2e/maestro", "test:mobile:e2e": "maestro test e2e/maestro",
"verify:fast": "npm run lint && npm run typecheck && npm test", "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: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", "verify": "npm run verify:full && npm run verify:e2e",
"db:start": "npx supabase start --workdir apps", "db:start": "npx supabase start --workdir apps",
"db:stop": "npx supabase stop --workdir apps", "db:stop": "npx supabase stop --workdir apps",
+44 -2
View File
@@ -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}" LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}"
SERVERS=( 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" "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" "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" "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." [ "$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() { assert_allowed_auth() {
local verdict local verdict
verdict="$(node - "$AUTH_URL" <<'NODE' verdict="$(node - "$AUTH_URL" <<'NODE'
@@ -569,6 +576,40 @@ cmd_seed() {
ok "Local migrations applied and configured fixtures created" 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() { cmd_native() {
local platform="$1" local platform="$1"
preflight preflight
@@ -591,9 +632,10 @@ case "${1:-up}" in
attach) tmux attach -t "$SESSION" ;; attach) tmux attach -t "$SESSION" ;;
reset) cmd_reset ;; reset) cmd_reset ;;
seed) cmd_seed ;; seed) cmd_seed ;;
e2e) cmd_e2e ;;
ios|android) cmd_native "$1" ;; ios|android) cmd_native "$1" ;;
help|-h|--help) help|-h|--help)
echo "usage: scripts/dev-local.sh up|down [--all]|status|logs <service>|restart <service>|attach|reset|seed|ios|android" echo "usage: scripts/dev-local.sh up|down [--all]|status|logs <service>|restart <service>|attach|reset|seed|e2e|ios|android"
;; ;;
*) die "unknown command '$1'" ;; *) die "unknown command '$1'" ;;
esac esac