After the React-codegen rework, ran the full e2e harness against the
docker-stack backend. Surfaced and fixed real friction:
mizan-base/src/index.ts (kernel):
- MizanError now parses both error envelopes — the FastAPI shape
({"error": {"code", "message", "details"}}) and the Django shape
({"error": true, "code", "message", "details"}). Exposes .code and
.details on the thrown error so consumer code can branch on them.
This was needed for the harness's `instanceof MizanError && error.code
=== 'NOT_FOUND'` pattern to work; the previous MizanError only carried
status + raw body, leaving callers to parse the body themselves.
examples/django-react-site/Dockerfile.test:
- Backend image now copies and installs cores/mizan-python before
installing mizan-django (which imports from mizan_core after the
Layer 1 extraction).
harness/src/fixtures.tsx:
- useRun helper updated for the new mutation-hook shape: pulls
{ mutate } off the hook result instead of treating the hook return
as a callable. Same for ValidationError fixture.
mizan.spec.ts:
- DjangoError → MizanError (kernel error class is backend-agnostic).
- Form tests removed (forms codegen deferred per Blazr scope).
- Channel test marked test.skip (channels deferred per Blazr scope).
.gitignore: ignore Playwright test-results/.
Final verification across all surfaces:
- mizan-core unit: 15/15
- mizan-django unit: 348 pass, 21 skip
- mizan-fastapi unit: 11/11
- mizan-ts edge-compat: 34/34 (cross-language HMAC pin)
- harness e2e (Playwright): 14/15 (1 skip = channels deferred)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
6.4 KiB
TypeScript
160 lines
6.4 KiB
TypeScript
/**
|
|
* mizan E2E Integration Tests
|
|
*
|
|
* Real Chromium → Real React app (generated hooks) → Real Django backend
|
|
*
|
|
* Every test uses the generated mizan API, not raw call() or fetch().
|
|
*/
|
|
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
const BASE = process.env.HARNESS_URL || 'http://localhost:5174'
|
|
|
|
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<any> {
|
|
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(),
|
|
}
|
|
}
|
|
|
|
// ─── useEcho, useAdd, useMultiply ───────────────────────────────────────────
|
|
|
|
test.describe('generated function hooks', () => {
|
|
test('useEcho returns echoed text', async ({ page }) => {
|
|
await fixture(page, 'echo')
|
|
const result = await getResult(page)
|
|
expect(result.message).toContain('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 (class-based ServerFunction) 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 handling ─────────────────────────────────────────────────────────
|
|
|
|
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`)
|
|
// Context loads async, wait for result
|
|
await page.waitForSelector('[data-testid="result"]', { timeout: 10000 })
|
|
const result = await getResult(page)
|
|
expect(result.authenticated).toBe(false)
|
|
expect(result.email).toBe('')
|
|
})
|
|
})
|
|
|
|
// ─── Form hooks ─── (removed; forms codegen deferred per Blazr scope) ──────
|
|
|
|
// ─── Channel hooks ──────────────────────────────────────────────────────────
|
|
|
|
test.describe('generated channel hooks', () => {
|
|
test.skip('useChatChannel receives echoed message', async ({ page }) => { // channels deferred per Blazr scope
|
|
await page.goto(`${BASE}#channel-chat`)
|
|
await page.waitForFunction(
|
|
() => {
|
|
const el = document.querySelector('[data-testid="channel-message-count"]')
|
|
return el && parseInt(el.textContent || '0') > 0
|
|
},
|
|
{ timeout: 15000 }
|
|
)
|
|
const msg = JSON.parse(await page.locator('[data-testid="channel-last-message"]').textContent())
|
|
expect(msg.text).toBe('hello from e2e')
|
|
})
|
|
})
|