mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
fix(e2e): align browser harness with local stack
This commit is contained in:
1 parent
db5980c9bd
commit
cf19d23d8a
20 files changed
+262
-80
No files matched your search
@@ -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 <service>`, `restart <service>`, `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 <service>`, `restart <service>`, `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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,6 +65,7 @@ const adminRepository = new PostgresAdminRepository(pool, {
|
||||
const app = createApp({
|
||||
auth: authVerifier,
|
||||
repository,
|
||||
corsAllowedOrigins: config.corsAllowedOrigins,
|
||||
coupleService: new CoupleService(coupleRepository),
|
||||
packsRepository,
|
||||
answersRepository,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-1
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+15
-4
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
+5
-13
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Generated
+1
-1
@@ -774,8 +774,8 @@
|
||||
"version": "1.0.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+44
-2
@@ -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 <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'" ;;
|
||||
esac
|
||||
Reference in new issue
Block a user