Move desktop and e2e into examples/ directory

- desktop/ → examples/django-react-desktop-app/
- e2e/ → examples/django-react-site/
- example/ → examples/django-react-site/backend/
- Update Dockerfile.test, Makefile, playwright config, and
  django.config.mjs path references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 20:41:20 -04:00
parent c866142770
commit eee352d908
51 changed files with 5983 additions and 10 deletions

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class TestAppConfig(AppConfig):
name = "testapp"
default_auto_field = "django.db.models.BigAutoField"
def ready(self):
import testapp.mizan_clients # noqa: F401

View File

@@ -0,0 +1,14 @@
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
django.setup()
from django.core.asgi import get_asgi_application
from mizan import wrap_asgi
# Register server functions and channels before building the ASGI app
import testapp.mizan_clients # noqa: F401
application = wrap_asgi(get_asgi_application())

View File

@@ -0,0 +1,411 @@
"""
Server functions and channels for integration tests.
Registers everything the React integration test suite expects:
- echo, add (HTTP + WebSocket RPC)
- login, signup, add_email forms
- chat, notifications, presence channels
"""
from django import forms
from django.http import HttpRequest
from pydantic import BaseModel
from mizan.client import ServerFunction, client
from mizan.channels import ReactChannel
from mizan.setup.registry import register, register_form, register_as
from mizan.channels import register as register_channel
from mizan.forms import mizanFormMixin, mizanFormMeta
from mizan.jwt import jwt_obtain, jwt_refresh
# =============================================================================
# Server Functions
# =============================================================================
class EchoOutput(BaseModel):
message: str
@client(websocket=True)
def echo(request: HttpRequest, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
class AddOutput(BaseModel):
result: int
@client(websocket=True)
def add(request: HttpRequest, a: int, b: int) -> AddOutput:
return AddOutput(result=a + b)
register(add, "add")
class WhoamiOutput(BaseModel):
user_id: int | None
email: str
is_staff: bool
@client(auth=True)
def whoami(request: HttpRequest) -> WhoamiOutput:
return WhoamiOutput(
user_id=getattr(request.user, "id", None),
email=getattr(request.user, "email", ""),
is_staff=getattr(request.user, "is_staff", False),
)
register(whoami, "whoami")
@client
def http_only_echo(request: HttpRequest, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(http_only_echo, "http_only_echo")
# =============================================================================
# Forms
# =============================================================================
class LoginForm(forms.Form):
login = forms.CharField(max_length=150, label="Login")
password = forms.CharField(widget=forms.PasswordInput, label="Password")
def handle_login(request, form):
"""Login form submit handler."""
from django.contrib.auth import authenticate, login
user = authenticate(
request,
username=form.cleaned_data["login"],
password=form.cleaned_data["password"],
)
if user is not None:
login(request, user)
return {"success": True}
form.add_error(None, "Invalid login credentials.")
return None # Signals validation failure
register_form(LoginForm, "login", submit_handler=handle_login)
class SignupForm(forms.Form):
email = forms.EmailField(label="Email")
password1 = forms.CharField(widget=forms.PasswordInput, label="Password")
def handle_signup(request, form):
"""Signup form submit handler."""
from django.contrib.auth import get_user_model
User = get_user_model()
try:
user = User.objects.create_user(
email=form.cleaned_data["email"],
password=form.cleaned_data["password1"],
)
return {"success": True, "data": {"user_id": user.pk}}
except Exception as e:
form.add_error(None, str(e))
return None
register_form(SignupForm, "signup", submit_handler=handle_signup)
class AddEmailForm(forms.Form):
email = forms.EmailField(label="Email address")
register_form(AddEmailForm, "add_email")
# =============================================================================
# Channels
# =============================================================================
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
class ReactMessage(BaseModel):
text: str
class DjangoMessage(BaseModel):
text: str
def authorize(self, params=None):
return True
def group(self, params=None):
room = params.room if params else "default"
return f"chat_{room}"
def receive(self, params, msg):
return self.DjangoMessage(text=msg.text)
register_channel(ChatChannel, "chat")
class NotificationsChannel(ReactChannel):
class DjangoMessage(BaseModel):
text: str
def authorize(self, params=None):
return True
def group(self, params=None):
return "notifications_global"
register_channel(NotificationsChannel, "notifications")
class PresenceChannel(ReactChannel):
class DjangoMessage(BaseModel):
value: int
def authorize(self, params=None):
return True
def group(self, params=None):
return "presence_global"
register_channel(PresenceChannel, "presence")
# =============================================================================
# Auth Variations
# =============================================================================
# --- Staff-only ---
@client(auth="staff")
def staff_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message=f"staff:{request.user.email}")
register(staff_only, "staff_only")
# --- Superuser-only ---
@client(auth="superuser")
def superuser_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message=f"superuser:{request.user.email}")
register(superuser_only, "superuser_only")
# --- Callable auth ---
def check_verified_email(request):
if not request.user.is_authenticated:
return False
return getattr(request.user, "email", "").endswith("@verified.com")
@client(auth=check_verified_email)
def verified_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message="verified")
register(verified_only, "verified_only")
# =============================================================================
# Context Functions
# =============================================================================
class CurrentUserOutput(BaseModel):
authenticated: bool
email: str
is_staff: bool
@client(context="global")
def current_user(request: HttpRequest) -> CurrentUserOutput:
if request.user.is_authenticated:
return CurrentUserOutput(
authenticated=True,
email=request.user.email,
is_staff=request.user.is_staff,
)
return CurrentUserOutput(authenticated=False, email="", is_staff=False)
register(current_user, "current_user")
class GreetOutput(BaseModel):
greeting: str
@client(context="local")
def greet(request: HttpRequest, name: str) -> GreetOutput:
return GreetOutput(greeting=f"Hello, {name}!")
register(greet, "greet")
# =============================================================================
# Class-based ServerFunction
# =============================================================================
class MultiplyInput(BaseModel):
x: int
y: int
class MultiplyOutput(BaseModel):
product: int
@register_as("multiply")
class Multiply(ServerFunction):
Input = MultiplyInput
Output = MultiplyOutput
def call(self, input: MultiplyInput) -> MultiplyOutput:
return MultiplyOutput(product=input.x * input.y)
# =============================================================================
# Error-producing Functions
# =============================================================================
@client
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
raise NotImplementedError("This feature is not yet implemented")
register(not_implemented_fn, "not_implemented_fn")
@client
def buggy_fn(request: HttpRequest) -> EchoOutput:
raise RuntimeError("Unexpected internal failure")
register(buggy_fn, "buggy_fn")
@client
def permission_check_fn(request: HttpRequest, secret: str) -> EchoOutput:
if secret != "open-sesame":
raise PermissionError("Wrong secret")
return EchoOutput(message="access granted")
register(permission_check_fn, "permission_check_fn")
# =============================================================================
# WebSocket + Auth Function
# =============================================================================
@client(websocket=True, auth=True)
def ws_whoami(request: HttpRequest) -> WhoamiOutput:
return WhoamiOutput(
user_id=getattr(request.user, "id", None),
email=getattr(request.user, "email", ""),
is_staff=getattr(request.user, "is_staff", False),
)
register(ws_whoami, "ws_whoami")
# =============================================================================
# mizanFormMixin Forms
# =============================================================================
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact",
title="Contact Us",
subtitle="We'd love to hear from you",
submit_label="Send Message",
live_validation=True,
live_form_errors=False,
)
name = forms.CharField(max_length=100, label="Your Name")
email = forms.EmailField(label="Email Address")
message = forms.CharField(widget=forms.Textarea, label="Message")
def on_submit_success(self, request):
return {"received": True, "from": self.cleaned_data["email"]}
# =============================================================================
# Formset-enabled Form
# =============================================================================
class ItemForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="item",
title="Items",
submit_label="Save Items",
enable_formset=True,
)
label = forms.CharField(max_length=50, label="Item Label")
quantity = forms.IntegerField(min_value=1, label="Quantity")
def on_submit_success(self, request):
return {
"label": self.cleaned_data["label"],
"qty": self.cleaned_data["quantity"],
}
# =============================================================================
# Auth-gated Channel
# =============================================================================
class PrivateChannel(ReactChannel):
class DjangoMessage(BaseModel):
text: str
def authorize(self, params=None):
return getattr(self.user, "is_authenticated", False)
def group(self, params=None):
return "private_global"
register_channel(PrivateChannel, "private")
# =============================================================================
# JWT Function Registration
# =============================================================================
register(jwt_obtain, "jwt_obtain")
register(jwt_refresh, "jwt_refresh")

View File

@@ -0,0 +1,29 @@
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
class EmailUserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError("Email is required")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
return self.create_user(email, password, **extra_fields)
class EmailUser(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True)
is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
objects = EmailUserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []

View File

@@ -0,0 +1,76 @@
"""
Django settings for the integration test backend.
Provides:
- HTTP server functions (echo, add)
- WebSocket channels (chat, notifications, presence)
- JWT authentication
- Form integration (login, signup, add_email)
"""
import os
SECRET_KEY = "integration-test-secret-key-not-for-production"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"mizan",
"testapp",
]
AUTH_USER_MODEL = "testapp.EmailUser"
MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
]
ROOT_URLCONF = "testapp.urls"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(os.path.dirname(__file__), "..", "db.sqlite3"),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ASGI_APPLICATION = "testapp.asgi.application"
# JWT
JWT_PRIVATE_KEY = "integration-test-jwt-secret-key"
JWT_ALGORITHM = "HS256"
# Channel layers — Redis when available, in-memory fallback for local dev
REDIS_URL = os.environ.get("REDIS_URL", "")
if REDIS_URL:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [REDIS_URL]},
},
}
else:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.db"
# CORS — allow React dev server
CSRF_TRUSTED_ORIGINS = [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
]

View File

@@ -0,0 +1,5 @@
from django.urls import include, path
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
]

View File

@@ -0,0 +1,186 @@
/**
* 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 → DjangoError NOT_FOUND', async ({ page }) => {
await fixture(page, 'not-found')
const error = await getError(page)
expect(error!.type).toBe('DjangoError')
expect(error!.code).toBe('NOT_FOUND')
})
test('wrong input types → DjangoError VALIDATION_ERROR', async ({ page }) => {
await fixture(page, 'validation-error')
const error = await getError(page)
expect(error!.type).toBe('DjangoError')
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('DjangoError')
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('DjangoError')
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('DjangoError')
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('DjangoError')
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('DjangoError')
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('DjangoError')
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('DjangoError')
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 ─────────────────────────────────────────────────────────────
test.describe('generated form hooks', () => {
test('useLoginForm loads schema with field definitions', async ({ page }) => {
await fixture(page, 'form-login-schema')
const result = await getResult(page)
expect(result.fields).toBeDefined()
expect(result.fields.login).toBeDefined()
expect(result.fields.password).toBeDefined()
})
test('useContactForm loads schema with mizanFormMeta', async ({ page }) => {
await fixture(page, 'form-contact-schema')
const result = await getResult(page)
expect(result.title).toBe('Contact Us')
expect(result.subtitle).toBe("We'd love to hear from you")
expect(result.submit_label).toBe('Send Message')
expect(result.meta.live_validation).toBe(true)
})
test('useContactForm submit returns on_submit_success data', async ({ page }) => {
await fixture(page, 'form-contact-submit')
const result = await getResult(page)
expect(result.success).toBe(true)
expect(result.data.received).toBe(true)
expect(result.data.from).toBe('test@example.com')
})
})
// ─── Channel hooks ──────────────────────────────────────────────────────────
test.describe('generated channel hooks', () => {
test('useChatChannel receives echoed message', async ({ page }) => {
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')
})
})

View File

@@ -0,0 +1,22 @@
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '../../..')
export default {
projectId: 'e2e-harness',
source: {
django: {
managePath: path.join(root, 'examples/django-react-site/backend/manage.py'),
command: [path.join(root, 'django/.venv/bin/python')],
env: {
PYTHONPATH: `${path.join(root, 'django/src')}:${path.join(root, 'examples/django-react-site/backend')}`,
DJANGO_SETTINGS_MODULE: 'testapp.settings',
},
},
},
output: 'src/api/generated.ts',
}

View File

@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8" /><title>mizan E2E Harness</title></head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>

View File

@@ -0,0 +1,22 @@
{
"name": "mizan-e2e-harness",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --port 5174"
},
"dependencies": {
"@rythazhur/mizan": "file:../../react",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,40 @@
'use client'
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
import { useChannel, type ChannelSubscription } from 'mizan/channels'
import type { ChatParams, ChatReactMessage, ChatDjangoMessage, NotificationsDjangoMessage, PresenceDjangoMessage, PrivateDjangoMessage } from './generated.channels'
// ============================================================================
// Channel Hooks
// ============================================================================
/**
* Hook for the chat channel.
*/
export function useChatChannel(params: ChatParams): ChannelSubscription<ChatParams, ChatDjangoMessage, ChatReactMessage> {
return useChannel('chat', params)
}
/**
* Hook for the notifications channel.
*/
export function useNotificationsChannel(): ChannelSubscription<Record<string, never>, NotificationsDjangoMessage, never> {
return useChannel('notifications', {})
}
/**
* Hook for the presence channel.
*/
export function usePresenceChannel(): ChannelSubscription<Record<string, never>, PresenceDjangoMessage, never> {
return useChannel('presence', {})
}
/**
* Hook for the private channel.
*/
export function usePrivateChannel(): ChannelSubscription<Record<string, never>, PrivateDjangoMessage, never> {
return useChannel('private', {})
}

View File

@@ -0,0 +1,268 @@
{
"openapi": "3.1.0",
"info": {
"title": "mizan Channels",
"version": "1.0.0",
"description": "Auto-generated schema for mizan channels"
},
"paths": {
"/channels/chat/params": {
"post": {
"operationId": "chatParams",
"summary": "Chat channel params",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BaseModel"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChatParams"
}
}
},
"required": true
}
}
},
"/channels/chat/react": {
"post": {
"operationId": "chatReactMessage",
"summary": "Chat React→Django message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BaseModel"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChatReactMessage"
}
}
},
"required": true
}
}
},
"/channels/chat/django": {
"post": {
"operationId": "chatDjangoMessage",
"summary": "Chat Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChatDjangoMessage"
}
}
}
}
}
}
},
"/channels/notifications/django": {
"post": {
"operationId": "notificationsDjangoMessage",
"summary": "Notifications Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationsDjangoMessage"
}
}
}
}
}
}
},
"/channels/presence/django": {
"post": {
"operationId": "presenceDjangoMessage",
"summary": "Presence Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresenceDjangoMessage"
}
}
}
}
}
}
},
"/channels/private/django": {
"post": {
"operationId": "privateDjangoMessage",
"summary": "Private Django→React message",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PrivateDjangoMessage"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"BaseModel": {
"properties": {},
"title": "BaseModel",
"type": "object"
},
"ChatParams": {
"properties": {
"room": {
"title": "Room",
"type": "string"
}
},
"required": [
"room"
],
"title": "ChatParams",
"type": "object"
},
"ChatReactMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "ChatReactMessage",
"type": "object"
},
"ChatDjangoMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "ChatDjangoMessage",
"type": "object"
},
"NotificationsDjangoMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "NotificationsDjangoMessage",
"type": "object"
},
"PresenceDjangoMessage": {
"properties": {
"value": {
"title": "Value",
"type": "integer"
}
},
"required": [
"value"
],
"title": "PresenceDjangoMessage",
"type": "object"
},
"PrivateDjangoMessage": {
"properties": {
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "PrivateDjangoMessage",
"type": "object"
}
}
},
"servers": [],
"x-mizan-channels": [
{
"name": "chat",
"pascalName": "Chat",
"hasParams": true,
"hasReactMessage": true,
"hasDjangoMessage": true,
"paramsType": "ChatParams",
"reactMessageType": "ChatReactMessage",
"djangoMessageType": "ChatDjangoMessage"
},
{
"name": "notifications",
"pascalName": "Notifications",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "NotificationsDjangoMessage"
},
{
"name": "presence",
"pascalName": "Presence",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "PresenceDjangoMessage"
},
{
"name": "private",
"pascalName": "Private",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "PrivateDjangoMessage"
}
]
}

View File

@@ -0,0 +1,337 @@
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// ============================================================================
// OpenAPI Types (generated by openapi-typescript)
// ============================================================================
export interface paths {
"/channels/chat/params": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Chat channel params */
post: operations["chatParams"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/chat/react": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Chat React→Django message */
post: operations["chatReactMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/chat/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Chat Django→React message */
post: operations["chatDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/notifications/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Notifications Django→React message */
post: operations["notificationsDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/presence/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Presence Django→React message */
post: operations["presenceDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/channels/private/django": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Private Django→React message */
post: operations["privateDjangoMessage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** BaseModel */
BaseModel: Record<string, never>;
/** ChatParams */
ChatParams: {
/** Room */
room: string;
};
/** ChatReactMessage */
ChatReactMessage: {
/** Text */
text: string;
};
/** ChatDjangoMessage */
ChatDjangoMessage: {
/** Text */
text: string;
};
/** NotificationsDjangoMessage */
NotificationsDjangoMessage: {
/** Text */
text: string;
};
/** PresenceDjangoMessage */
PresenceDjangoMessage: {
/** Value */
value: number;
};
/** PrivateDjangoMessage */
PrivateDjangoMessage: {
/** Text */
text: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
chatParams: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChatParams"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BaseModel"];
};
};
};
};
chatReactMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChatReactMessage"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BaseModel"];
};
};
};
};
chatDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ChatDjangoMessage"];
};
};
};
};
notificationsDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NotificationsDjangoMessage"];
};
};
};
};
presenceDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PresenceDjangoMessage"];
};
};
};
};
privateDjangoMessage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PrivateDjangoMessage"];
};
};
};
};
}
// ============================================================================
// Convenience Type Exports
// ============================================================================
export type ChatParams = components["schemas"]["ChatParams"]
export type ChatReactMessage = components["schemas"]["ChatReactMessage"]
export type ChatDjangoMessage = components["schemas"]["ChatDjangoMessage"]
export type NotificationsDjangoMessage = components["schemas"]["NotificationsDjangoMessage"]
export type PresenceDjangoMessage = components["schemas"]["PresenceDjangoMessage"]
export type PrivateDjangoMessage = components["schemas"]["PrivateDjangoMessage"]
// ============================================================================
// Channel Registry
// ============================================================================
export const CHANNELS = {
chat: {
name: 'chat',
pascalName: 'Chat',
hasParams: true,
hasReactMessage: true,
hasDjangoMessage: true,
paramsType: 'ChatParams',
reactMessageType: 'ChatReactMessage',
djangoMessageType: 'ChatDjangoMessage',
},
notifications: {
name: 'notifications',
pascalName: 'Notifications',
hasParams: false,
hasReactMessage: false,
hasDjangoMessage: true,
djangoMessageType: 'NotificationsDjangoMessage',
},
presence: {
name: 'presence',
pascalName: 'Presence',
hasParams: false,
hasReactMessage: false,
hasDjangoMessage: true,
djangoMessageType: 'PresenceDjangoMessage',
},
private: {
name: 'private',
pascalName: 'Private',
hasParams: false,
hasReactMessage: false,
hasDjangoMessage: true,
djangoMessageType: 'PrivateDjangoMessage',
},
} as const

View File

@@ -0,0 +1,62 @@
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
//
// Server-side functions for SSR hydration.
// These run in Next.js server components/layouts.
import type { currentUserOutput, greetOutput } from './generated.mizan'
// ============================================================================
// Hydration Types
// ============================================================================
/** Typed hydration data for SSR */
export interface DjangoHydration {
currentUser?: currentUserOutput
greet?: greetOutput
}
// ============================================================================
// SSR Hydration Helper
// ============================================================================
/**
* Fetch hydration data for SSR.
*
* Call this in your server component:
* const hydration = await getDjangoHydration(client)
* return <DjangoContext hydration={hydration}>...</DjangoContext>
*/
export async function getDjangoHydration(
client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }
): Promise<DjangoHydration> {
const hydration: DjangoHydration = {}
const results = await Promise.allSettled([
client.request('POST', '/api/mizan/call/', { fn: 'current_user', args: {} }),
client.request('POST', '/api/mizan/call/', { fn: 'greet', args: {} }),
])
if (results[0].status === 'fulfilled') {
const data = await (results[0] as PromiseFulfilledResult<Response>).value.json()
if (data.error) {
console.error('[getDjangoHydration] current_user failed:', data.code, data.message)
} else {
hydration.currentUser = data.data
}
} else {
console.error('[getDjangoHydration] current_user request failed:', (results[0] as PromiseRejectedResult).reason)
}
if (results[1].status === 'fulfilled') {
const data = await (results[1] as PromiseFulfilledResult<Response>).value.json()
if (data.error) {
console.error('[getDjangoHydration] greet failed:', data.code, data.message)
} else {
hydration.greet = data.data
}
} else {
console.error('[getDjangoHydration] greet request failed:', (results[1] as PromiseRejectedResult).reason)
}
return hydration
}

View File

@@ -0,0 +1,257 @@
'use client'
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// This file provides typed wrappers around the mizan library.
// - DjangoContext: Typed provider wrapping mizanProvider
// - Typed hooks: useAuthStatus(), useUser(), etc.
import { type ReactNode, useCallback } from 'react'
import {
mizanProvider,
usemizan,
usemizanContext,
usemizanCall,
type mizanHydration,
type Transport,
} from 'mizan'
import { ChannelProvider, ChannelConnection } from 'mizan/channels'
import { useRef } from 'react'
import type { addEmailSchemaOutput, addEmailValidateInput, addEmailValidateOutput, addInput, addOutput, buggyFnOutput, contactSchemaInput, contactSchemaOutput, contactSubmitOutput, contactValidateInput, contactValidateOutput, currentUserOutput, echoInput, echoOutput, greetInput, greetOutput, httpOnlyEchoInput, httpOnlyEchoOutput, itemFormsetSchemaInput, itemFormsetSchemaOutput, itemFormsetSubmitInput, itemFormsetSubmitOutput, itemFormsetValidateInput, itemFormsetValidateOutput, itemSchemaInput, itemSchemaOutput, itemSubmitOutput, itemValidateInput, itemValidateOutput, jwtObtainOutput, jwtRefreshInput, jwtRefreshOutput, loginSchemaOutput, loginSubmitInput, loginSubmitOutput, loginValidateInput, loginValidateOutput, multiplyInput, multiplyOutput, notImplementedFnOutput, permissionCheckFnInput, permissionCheckFnOutput, signupSchemaOutput, signupSubmitInput, signupSubmitOutput, signupValidateInput, signupValidateOutput, staffOnlyOutput, superuserOnlyOutput, verifiedOnlyOutput, whoamiOutput, wsWhoamiOutput } from './generated.mizan'
// ============================================================================
// Hydration Types
// ============================================================================
/** Typed hydration data for SSR */
export interface DjangoHydration {
currentUser?: currentUserOutput
greet?: greetOutput
}
/** Convert typed hydration to mizan format */
function tomizanHydration(hydration?: DjangoHydration): mizanHydration | undefined {
if (!hydration) return undefined
const result: mizanHydration = {}
if (hydration.currentUser !== undefined) result['current_user'] = hydration.currentUser
if (hydration.greet !== undefined) result['greet'] = hydration.greet
return result
}
// ============================================================================
// Provider
// ============================================================================
export interface DjangoContextProps {
children: ReactNode
/** SSR hydration data */
hydration?: DjangoHydration
/** WebSocket URL for RPC calls (default: /ws/) */
wsUrl?: string
/** Base URL for HTTP fallback (default: /api/mizan) */
baseUrl?: string
}
/**
* Typed Django context provider.
*
* Wraps mizanProvider with:
* - Typed hydration
* - Auto-fetch for registered contexts
*
* Usage:
* <DjangoContext hydration={hydration}>
* <App />
* </DjangoContext>
*/
export function DjangoContext({
children,
hydration,
wsUrl,
baseUrl,
}: DjangoContextProps) {
const connectionRef = useRef<ChannelConnection | null>(null)
if (!connectionRef.current) {
connectionRef.current = new ChannelConnection({ url: wsUrl || '/ws/' })
}
return (
<mizanProvider
hydration={tomizanHydration(hydration)}
contexts={['current_user', 'greet']}
wsUrl={wsUrl}
baseUrl={baseUrl}
connection={connectionRef.current}
>
<ChannelProvider connection={connectionRef.current} autoConnect={true}>
{children}
</ChannelProvider>
</mizanProvider>
)
}
// ============================================================================
// Context Hooks (typed wrappers)
// ============================================================================
/**
* Get current_user context data.
* @throws if context not loaded yet
*/
export function useCurrentUser(): currentUserOutput {
const data = usemizanContext<currentUserOutput>('current_user')
if (data === undefined) {
throw new Error('useCurrentUser: context not loaded yet')
}
return data
}
/**
* Get greet context data.
* @throws if context not loaded yet
*/
export function useGreet(): greetOutput {
const data = usemizanContext<greetOutput>('greet')
if (data === undefined) {
throw new Error('useGreet: context not loaded yet')
}
return data
}
/**
* Get context refresh functions without subscribing to data changes.
* Use this in components that only need to trigger refreshes.
*/
export function useDjangoRefresh() {
const { refreshContext, refreshAllContexts } = usemizan()
return {
refreshCurrentUser: () => refreshContext('current_user'),
refreshGreet: () => refreshContext('greet'),
refreshAll: refreshAllContexts,
}
}
// ============================================================================
// Function Hooks (typed wrappers)
// ============================================================================
/**
* Call echo server function.
* Transport: websocket
*/
export function useEcho() {
return usemizanCall<echoInput, echoOutput>('echo', 'websocket')
}
/**
* Call add server function.
* Transport: websocket
*/
export function useAdd() {
return usemizanCall<addInput, addOutput>('add', 'websocket')
}
/**
* Call whoami server function.
* Transport: http
*/
export function useWhoami() {
return usemizanCall<void, whoamiOutput>('whoami', 'http')
}
/**
* Call http_only_echo server function.
* Transport: http
*/
export function useHttpOnlyEcho() {
return usemizanCall<httpOnlyEchoInput, httpOnlyEchoOutput>('http_only_echo', 'http')
}
/**
* Call staff_only server function.
* Transport: http
*/
export function useStaffOnly() {
return usemizanCall<void, staffOnlyOutput>('staff_only', 'http')
}
/**
* Call superuser_only server function.
* Transport: http
*/
export function useSuperuserOnly() {
return usemizanCall<void, superuserOnlyOutput>('superuser_only', 'http')
}
/**
* Call verified_only server function.
* Transport: http
*/
export function useVerifiedOnly() {
return usemizanCall<void, verifiedOnlyOutput>('verified_only', 'http')
}
/**
* Call multiply server function.
* Transport: http
*/
export function useMultiply() {
return usemizanCall<multiplyInput, multiplyOutput>('multiply', 'http')
}
/**
* Call not_implemented_fn server function.
* Transport: http
*/
export function useNotImplementedFn() {
return usemizanCall<void, notImplementedFnOutput>('not_implemented_fn', 'http')
}
/**
* Call buggy_fn server function.
* Transport: http
*/
export function useBuggyFn() {
return usemizanCall<void, buggyFnOutput>('buggy_fn', 'http')
}
/**
* Call permission_check_fn server function.
* Transport: http
*/
export function usePermissionCheckFn() {
return usemizanCall<permissionCheckFnInput, permissionCheckFnOutput>('permission_check_fn', 'http')
}
/**
* Call ws_whoami server function.
* Transport: websocket
*/
export function useWsWhoami() {
return usemizanCall<void, wsWhoamiOutput>('ws_whoami', 'websocket')
}
/**
* Call jwt_obtain server function.
* Transport: http
*/
export function useJwtObtain() {
return usemizanCall<void, jwtObtainOutput>('jwt_obtain', 'http')
}
/**
* Call jwt_refresh server function.
* Transport: http
*/
export function useJwtRefresh() {
return usemizanCall<jwtRefreshInput, jwtRefreshOutput>('jwt_refresh', 'http')
}
// ============================================================================
// Re-exports from mizan library
// ============================================================================
export { usemizan, usemizanStatus, usePush, DjangoError } from 'mizan'
export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
'use client'
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// Typed form hooks with Zod validation.
// Zod schemas are generated from Django form field definitions.
// Client-side validation matches Django constraints (required, max_length, email, etc.)
import { z } from 'zod'
import {
useDjangoFormCore,
useDjangoFormsetCore,
type DjangoFormState,
type DjangoFormsetState,
type FormOptions,
} from 'mizan'
// ============================================================================
// Zod Schemas
// ============================================================================
/**
* Zod schema for login form
* Generated from Django form field definitions
*/
export const LoginSchema = z.object({
})
/**
* Zod schema for signup form
* Generated from Django form field definitions
*/
export const SignupSchema = z.object({
})
/**
* Zod schema for add_email form
* Generated from Django form field definitions
*/
export const AddEmailSchema = z.object({
})
/**
* Zod schema for contact form
* Generated from Django form field definitions
*/
export const ContactSchema = z.object({
name: z.string().max(100),
email: z.string().email('Invalid email address').max(320),
message: z.string(),
})
/**
* Zod schema for item form
* Generated from Django form field definitions
*/
export const ItemSchema = z.object({
label: z.string().max(50),
quantity: z.number().int().min(1),
})
// ============================================================================
// Form Data Types (inferred from Zod schemas)
// ============================================================================
/** Form data type for login, inferred from Zod schema */
export type LoginFormData = z.infer<typeof LoginSchema>
/** Form data type for signup, inferred from Zod schema */
export type SignupFormData = z.infer<typeof SignupSchema>
/** Form data type for add_email, inferred from Zod schema */
export type AddEmailFormData = z.infer<typeof AddEmailSchema>
/** Form data type for contact, inferred from Zod schema */
export type ContactFormData = z.infer<typeof ContactSchema>
/** Form data type for item, inferred from Zod schema */
export type ItemFormData = z.infer<typeof ItemSchema>
// ============================================================================
// Form Hooks
// ============================================================================
/**
* Typed form hook for login
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useLoginForm(
options?: FormOptions
): DjangoFormState<LoginFormData> {
return useDjangoFormCore<LoginFormData>({
name: 'login',
zodSchema: LoginSchema,
options,
})
}
/**
* Typed form hook for signup
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useSignupForm(
options?: FormOptions
): DjangoFormState<SignupFormData> {
return useDjangoFormCore<SignupFormData>({
name: 'signup',
zodSchema: SignupSchema,
options,
})
}
/**
* Typed form hook for add_email
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useAddEmailForm(
options?: FormOptions
): DjangoFormState<AddEmailFormData> {
return useDjangoFormCore<AddEmailFormData>({
name: 'add_email',
zodSchema: AddEmailSchema,
options,
})
}
/**
* Typed form hook for contact
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useContactForm(
options?: FormOptions
): DjangoFormState<ContactFormData> {
return useDjangoFormCore<ContactFormData>({
name: 'contact',
zodSchema: ContactSchema,
options,
})
}
/**
* Typed form hook for item
*
* Features:
* - Full TypeScript inference for form fields
* - Client-side Zod validation (instant feedback)
* - Server-side Django validation (authoritative)
*/
export function useItemForm(
options?: FormOptions
): DjangoFormState<ItemFormData> {
return useDjangoFormCore<ItemFormData>({
name: 'item',
zodSchema: ItemSchema,
options,
})
}
/**
* Typed formset hook for item
*/
export function useItemFormset(
initialCount?: number,
liveValidation?: boolean
): DjangoFormsetState<ItemFormData> {
return useDjangoFormsetCore<ItemFormData>({
name: 'item',
zodSchema: ItemSchema,
initialCount,
liveValidation,
})
}
// ============================================================================
// Form Registry
// ============================================================================
export const DJANGO_FORMS = {
login: {
name: 'login',
schema: LoginSchema,
hook: 'useLoginForm',
hasFormset: false,
},
signup: {
name: 'signup',
schema: SignupSchema,
hook: 'useSignupForm',
hasFormset: false,
},
addEmail: {
name: 'add_email',
schema: AddEmailSchema,
hook: 'useAddEmailForm',
hasFormset: false,
},
contact: {
name: 'contact',
schema: ContactSchema,
hook: 'useContactForm',
hasFormset: false,
},
item: {
name: 'item',
schema: ItemSchema,
hook: 'useItemForm',
hasFormset: true,
},
} as const

View File

@@ -0,0 +1,90 @@
/**
* mizan API - Consolidated Exports
*
* Import everything from here:
*
* @example
* ```tsx
* import {
* DjangoContext,
* useUser,
* useEcho,
* useChatChannel,
* DjangoError,
* } from '@/api'
* ```
*/
// AUTO-GENERATED by mizan - do not edit manually
// Regenerate with: npm run schemas
// =============================================================================
// mizan Provider & Hooks
// =============================================================================
export {
getDjangoHydration,
type DjangoHydration,
} from './generated.django.server'
export {
// Provider
DjangoContext,
type DjangoContextProps,
// Context hooks
useCurrentUser,
useGreet,
// Refresh hooks
useDjangoRefresh,
// Function hooks
useEcho,
useAdd,
useWhoami,
useHttpOnlyEcho,
useStaffOnly,
useSuperuserOnly,
useVerifiedOnly,
useMultiply,
useNotImplementedFn,
useBuggyFn,
usePermissionCheckFn,
useWsWhoami,
useJwtObtain,
useJwtRefresh,
// Re-exports from mizan library
usemizan,
usemizanStatus,
usePush,
DjangoError,
type ConnectionStatus,
type PushMessage,
type PushListener,
} from './generated.django'
// =============================================================================
// Channel Hooks
// =============================================================================
export {
useChatChannel,
useNotificationsChannel,
usePresenceChannel,
usePrivateChannel,
} from './generated.channels.hooks'
// =============================================================================
// Channel Types
// =============================================================================
export type {
ChatParams,
ChatReactMessage,
ChatDjangoMessage,
NotificationsDjangoMessage,
PresenceDjangoMessage,
PrivateDjangoMessage,
} from './generated.channels'

View File

@@ -0,0 +1,264 @@
/**
* 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 {
DjangoContext,
useEcho,
useAdd,
useMultiply,
useWhoami,
useStaffOnly,
useSuperuserOnly,
useVerifiedOnly,
useNotImplementedFn,
useBuggyFn,
usePermissionCheckFn,
useCurrentUser,
DjangoError,
useMizan,
} from './api/generated.django'
import { useContactForm, useLoginForm } from './api/generated.forms'
import { useChatChannel } from './api/generated.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 />
case 'form-login-schema': return <FormLoginSchema />
case 'form-contact-schema': return <FormContactSchema />
case 'form-contact-submit': return <FormContactSubmit />
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 DjangoError ? 'DjangoError' : 'Error'}
</div>
<div data-testid="error-code">
{error instanceof DjangoError ? 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: () => (input?: any) => Promise<T>, input?: any) {
const call = hook()
const [data, setData] = useState<T>()
const [error, setError] = useState<unknown>()
useEffect(() => {
call(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 call = useAdd()
const [error, setError] = useState<unknown>()
useEffect(() => { (call as any)({ a: 'not_a_number', b: 'also_not' }).catch(setError) }, [call])
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>
}
}
// ─── Form fixtures (using generated form hooks) ─────────────────────────────
function FormLoginSchema() {
const form = useLoginForm()
if (form.loading) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify(form.schema)}</pre>
}
function FormContactSchema() {
const form = useContactForm()
if (form.loading) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify(form.schema)}</pre>
}
function FormContactSubmit() {
const form = useContactForm()
const [result, setResult] = useState<unknown>()
const [submitted, setSubmitted] = useState(false)
useEffect(() => {
if (!form.loading && !submitted) {
form.set('name', 'Test User')
form.set('email', 'test@example.com')
form.set('message', 'Hello from e2e')
setSubmitted(true)
}
}, [form.loading, submitted, form])
useEffect(() => {
if (submitted && !result) {
form.submit().then(setResult)
}
}, [submitted, result, form])
if (!result) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify(result)}</pre>
}
// ─── Channel fixtures ───────────────────────────────────────────────────────
function ChannelChatFixture() {
// DjangoContext 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>
)
}

View File

@@ -0,0 +1,13 @@
import { createRoot } from 'react-dom/client'
import { DjangoContext } from './api/generated.django'
import { Fixtures } from './fixtures'
function App() {
return (
<DjangoContext baseUrl="/api/mizan">
<Fixtures />
</DjangoContext>
)
}
createRoot(document.getElementById('root')!).render(<App />)

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
const reactPkg = path.resolve(__dirname, '../../react/src')
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'mizan/client/react': path.join(reactPkg, 'client/react.ts'),
'mizan/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'),
'mizan/client': path.join(reactPkg, 'client/index.ts'),
'mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
'mizan/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'),
'mizan/allauth': path.join(reactPkg, 'allauth/index.ts'),
'mizan': path.join(reactPkg, 'index.ts'),
'@rythazhur/mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'@rythazhur/mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
'@rythazhur/mizan': path.join(reactPkg, 'index.ts'),
},
},
server: {
proxy: {
'/api': 'http://localhost:8000',
'/ws': { target: 'ws://localhost:8000', ws: true },
},
},
})