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>
224 lines
7.7 KiB
TypeScript
224 lines
7.7 KiB
TypeScript
/**
|
|
* E2E Test Fixtures
|
|
*
|
|
* Each fixture uses GENERATED mizan hooks (not raw call()).
|
|
* Playwright reads the DOM to verify behavior.
|
|
*
|
|
* URL hash selects the fixture: #echo, #add, #multiply, etc.
|
|
*/
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
// Generated typed hooks — the actual mizan API
|
|
import {
|
|
MizanContext,
|
|
useEcho,
|
|
useAdd,
|
|
useMultiply,
|
|
useWhoami,
|
|
useStaffOnly,
|
|
useSuperuserOnly,
|
|
useVerifiedOnly,
|
|
useNotImplementedFn,
|
|
useBuggyFn,
|
|
usePermissionCheckFn,
|
|
useCurrentUser,
|
|
MizanError,
|
|
useMizan,
|
|
} from './api'
|
|
import { useChatChannel } from './api/channels.hooks'
|
|
|
|
// ─── Fixture router ─────────────────────────────────────────────────────────
|
|
|
|
export function Fixtures() {
|
|
const [hash, setHash] = useState(window.location.hash.slice(1))
|
|
|
|
useEffect(() => {
|
|
const onHash = () => setHash(window.location.hash.slice(1))
|
|
window.addEventListener('hashchange', onHash)
|
|
return () => window.removeEventListener('hashchange', onHash)
|
|
}, [])
|
|
|
|
switch (hash) {
|
|
case 'echo': return <Echo />
|
|
case 'add': return <Add />
|
|
case 'multiply': return <Multiply />
|
|
case 'not-found': return <NotFound />
|
|
case 'validation-error': return <ValidationError />
|
|
case 'auth-required': return <AuthRequired />
|
|
case 'staff-only': return <StaffOnly />
|
|
case 'superuser-only': return <SuperuserOnly />
|
|
case 'verified-only': return <VerifiedOnly />
|
|
case 'not-implemented': return <NotImplemented />
|
|
case 'internal-error': return <InternalError />
|
|
case 'permission-error': return <PermissionError_ />
|
|
case 'permission-success': return <PermissionSuccess />
|
|
case 'context-current-user': return <ContextCurrentUser />
|
|
// Form fixtures removed — forms codegen deferred per Blazr scope
|
|
case 'channel-chat': return <ChannelChatFixture />
|
|
default: return <div data-testid="ready">Harness ready. Set #hash.</div>
|
|
}
|
|
}
|
|
|
|
// ─── Result helper ──────────────────────────────────────────────────────────
|
|
|
|
function Result({ data, error }: { data?: unknown; error?: unknown }) {
|
|
return (
|
|
<>
|
|
{data !== undefined && (
|
|
<pre data-testid="result">{JSON.stringify(data)}</pre>
|
|
)}
|
|
{error !== undefined && error !== null && (
|
|
<>
|
|
<div data-testid="error-type">
|
|
{error instanceof MizanError ? 'MizanError' : 'Error'}
|
|
</div>
|
|
<div data-testid="error-code">
|
|
{error instanceof MizanError ? error.code : ''}
|
|
</div>
|
|
<pre data-testid="error-message">
|
|
{error instanceof Error ? error.message : String(error)}
|
|
</pre>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ─── Hook runner: calls a generated hook and renders result ─────────────────
|
|
|
|
function useRun<T>(hook: () => { mutate: (input?: any) => Promise<T> }, input?: any) {
|
|
const { mutate } = hook()
|
|
const [data, setData] = useState<T>()
|
|
const [error, setError] = useState<unknown>()
|
|
|
|
useEffect(() => {
|
|
mutate(input).then(setData).catch(setError)
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return { data, error }
|
|
}
|
|
|
|
// ─── Server function fixtures ───────────────────────────────────────────────
|
|
|
|
function Echo() {
|
|
const { data, error } = useRun(useEcho, { text: 'e2e-test' })
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function Add() {
|
|
const { data, error } = useRun(useAdd, { a: 17, b: 25 })
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function Multiply() {
|
|
const { data, error } = useRun(useMultiply, { x: 6, y: 7 })
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function NotFound() {
|
|
// Deliberately call a non-existent function via the raw primitive
|
|
const { call } = useMizan()
|
|
const [error, setError] = useState<unknown>()
|
|
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
|
|
return <Result error={error} />
|
|
}
|
|
|
|
function ValidationError() {
|
|
// Send wrong types to add (strings instead of numbers)
|
|
const { mutate } = useAdd()
|
|
const [error, setError] = useState<unknown>()
|
|
useEffect(() => { (mutate as any)({ a: 'not_a_number', b: 'also_not' }).catch(setError) }, [mutate])
|
|
return <Result error={error} />
|
|
}
|
|
|
|
function AuthRequired() {
|
|
const { data, error } = useRun(useWhoami)
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function StaffOnly() {
|
|
const { data, error } = useRun(useStaffOnly)
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function SuperuserOnly() {
|
|
const { data, error } = useRun(useSuperuserOnly)
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function VerifiedOnly() {
|
|
const { data, error } = useRun(useVerifiedOnly)
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function NotImplemented() {
|
|
const { data, error } = useRun(useNotImplementedFn)
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function InternalError() {
|
|
const { data, error } = useRun(useBuggyFn)
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function PermissionError_() {
|
|
const { data, error } = useRun(usePermissionCheckFn, { secret: 'wrong' })
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
function PermissionSuccess() {
|
|
const { data, error } = useRun(usePermissionCheckFn, { secret: 'open-sesame' })
|
|
return <Result data={data} error={error} />
|
|
}
|
|
|
|
// ─── Context fixtures ───────────────────────────────────────────────────────
|
|
|
|
function ContextCurrentUser() {
|
|
// useCurrentUser throws if context not loaded yet, so catch that
|
|
try {
|
|
const user = useCurrentUser()
|
|
return <pre data-testid="result">{JSON.stringify(user)}</pre>
|
|
} catch {
|
|
return <div>loading context...</div>
|
|
}
|
|
}
|
|
|
|
// ─── Channel fixtures ───────────────────────────────────────────────────────
|
|
|
|
function ChannelChatFixture() {
|
|
// MizanContext already includes ChannelProvider
|
|
return <ChannelChat />
|
|
}
|
|
|
|
function ChannelChat() {
|
|
const chat = useChatChannel({ room: 'e2e' })
|
|
const [sent, setSent] = useState(false)
|
|
const prevStatus = useRef(chat.status)
|
|
|
|
useEffect(() => {
|
|
// Send once when status transitions to 'connected' (meaning subscribed)
|
|
// The hook maps subscribed → 'connected', but we need to wait for it
|
|
// to go through 'connecting' first (before subscription is confirmed)
|
|
const wasConnecting = prevStatus.current === 'connecting'
|
|
prevStatus.current = chat.status
|
|
|
|
if (wasConnecting && chat.status === 'connected' && !sent) {
|
|
chat.send({ text: 'hello from e2e' })
|
|
setSent(true)
|
|
}
|
|
}, [chat.status, sent, chat])
|
|
|
|
return (
|
|
<div>
|
|
<div data-testid="channel-status">{chat.status}</div>
|
|
<div data-testid="channel-message-count">{chat.messages.length}</div>
|
|
{chat.messages.length > 0 && (
|
|
<pre data-testid="channel-last-message">
|
|
{JSON.stringify(chat.messages[chat.messages.length - 1])}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|