/** * mizan-fastapi e2e — Real Chromium → React with generated hooks → real FastAPI server. * * Mirrors examples/django-react-site/mizan.spec.ts minus channel/form tests * (those features are Django-only — FastAPI projects use native equivalents * or skip them entirely). */ import { test, expect } from '@playwright/test' const BASE = process.env.HARNESS_URL || 'http://localhost:5175' async function fixture(page: any, name: string) { await page.goto(`${BASE}#${name}`) await page.waitForSelector('[data-testid="result"], [data-testid="error-type"]', { timeout: 10000 }) } async function getResult(page: any): Promise { const el = page.locator('[data-testid="result"]') if (await el.count() > 0) return JSON.parse(await el.textContent()) return null } async function getError(page: any) { const typeEl = page.locator('[data-testid="error-type"]') if (await typeEl.count() === 0) return null return { type: await typeEl.textContent(), code: await page.locator('[data-testid="error-code"]').textContent(), message: await page.locator('[data-testid="error-message"]').textContent(), } } // ─── Function hooks ───────────────────────────────────────────────────────── test.describe('generated function hooks', () => { test('useEcho returns echoed text', async ({ page }) => { await fixture(page, 'echo') const result = await getResult(page) expect(result.message).toBe('e2e-test') }) test('useAdd returns correct sum', async ({ page }) => { await fixture(page, 'add') const result = await getResult(page) expect(result.result).toBe(42) }) test('useMultiply returns product', async ({ page }) => { await fixture(page, 'multiply') const result = await getResult(page) expect(result.product).toBe(42) }) test('usePermissionCheckFn succeeds with correct secret', async ({ page }) => { await fixture(page, 'permission-success') const result = await getResult(page) expect(result.message).toBe('access granted') }) }) // ─── Error codes ──────────────────────────────────────────────────────────── test.describe('error codes from generated hooks', () => { test('non-existent function → MizanError NOT_FOUND', async ({ page }) => { await fixture(page, 'not-found') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(error!.code).toBe('NOT_FOUND') }) test('wrong input types → MizanError VALIDATION_ERROR', async ({ page }) => { await fixture(page, 'validation-error') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(error!.code).toBe('VALIDATION_ERROR') }) test('useWhoami anonymous → auth error', async ({ page }) => { await fixture(page, 'auth-required') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) }) test('useStaffOnly anonymous → UNAUTHORIZED', async ({ page }) => { await fixture(page, 'staff-only') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) }) test('useSuperuserOnly anonymous → UNAUTHORIZED', async ({ page }) => { await fixture(page, 'superuser-only') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) }) test('useVerifiedOnly anonymous → FORBIDDEN', async ({ page }) => { await fixture(page, 'verified-only') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) }) test('useNotImplementedFn → NOT_IMPLEMENTED', async ({ page }) => { await fixture(page, 'not-implemented') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(error!.code).toBe('NOT_IMPLEMENTED') }) test('useBuggyFn → INTERNAL_ERROR', async ({ page }) => { await fixture(page, 'internal-error') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(error!.code).toBe('INTERNAL_ERROR') }) test('usePermissionCheckFn wrong secret → FORBIDDEN', async ({ page }) => { await fixture(page, 'permission-error') const error = await getError(page) expect(error!.type).toBe('MizanError') expect(error!.code).toBe('FORBIDDEN') }) }) // ─── Context hooks ────────────────────────────────────────────────────────── test.describe('generated context hooks', () => { test('useCurrentUser returns anonymous data', async ({ page }) => { await page.goto(`${BASE}#context-current-user`) await page.waitForSelector('[data-testid="result"]', { timeout: 10000 }) const result = await getResult(page) expect(result.authenticated).toBe(false) expect(result.email).toBe('') }) }) // ─── Session gate ─────────────────────────────────────────────────────────── test.describe('session gate', () => { test('no /session/ requests fire when configured with session={false}', async ({ page }) => { const sessionCalls: string[] = [] page.on('request', (req) => { const url = req.url() if (url.includes('/session/')) sessionCalls.push(url) }) await fixture(page, 'echo') await getResult(page) expect(sessionCalls).toEqual([]) }) }) // ─── Merge protocol ───────────────────────────────────────────────────────── test.describe('merge protocol', () => { test('@client(merge=...) routes to the correct slot, leaves siblings untouched, no refetch', async ({ page }) => { // The morphs context bundles two list slots: // morph_groups: list[MorphGroupMeta] — {id, label, count} // morph_layers: list[MorphLayer] — {id, group_id, label, value} // set_morph_value returns MorphLayer. Server-side slot resolution // (via mizan_core.type_utils.types_match_for_merge) must route to // morph_layers and leave morph_groups intact. A kernel-side heuristic // would have to guess between the two id-bearing list slots. const morphsFetches: string[] = [] page.on('request', (req) => { const url = req.url() if (url.includes('/api/mizan/ctx/morphs/')) morphsFetches.push(url) }) await page.goto(`${BASE}#merge-morph`) await page.waitForFunction(() => { const el = document.querySelector('[data-testid="result"]') if (!el) return false try { const data = JSON.parse(el.textContent!) return data.layers?.some((l: any) => l.id === 1 && l.value === 0.75) } catch { return false } }, { timeout: 5000 }) const result = await getResult(page) const layer = result.layers.find((l: any) => l.id === 1) expect(layer.value).toBe(0.75) expect(layer.label).toBe('brow') // Sibling slot is unchanged — the server didn't route MorphLayer into morph_groups. expect(result.groups).toEqual([{ id: 1, label: 'face', count: 2 }]) // Initial mount fetches once; merge path must not trigger a refetch. expect(morphsFetches.length).toBe(1) }) })