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

+15 -4
View File
@@ -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.
+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": {
"@playwright/test": "^1.50.1",
"@supabase/supabase-js": "^2.49.1",
"@types/node": "^20.17.19",
"pg": "^8.17.2",
"typescript": "^5.7.3"
}
}
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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
View File
@@ -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();
}
});