mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10: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
+267
-85
No files matched your search
+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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user