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:
96
examples/django-react-desktop-app/app.py
Normal file
96
examples/django-react-desktop-app/app.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
mizan Desktop — PyWebView + Django local RPC.
|
||||
|
||||
Starts a local Django ASGI server and opens a native desktop window.
|
||||
All communication between the UI and backend uses mizan server functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
|
||||
# Work around Qt WebEngine GPU crashes on some systems
|
||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||
|
||||
|
||||
def start_server(host: str, port: int):
|
||||
"""Start the Django ASGI server in a background thread."""
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
# Run migrations on first launch
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("migrate", "--run-syncdb", verbosity=0)
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"backend.asgi:application",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="warning",
|
||||
)
|
||||
|
||||
|
||||
def wait_for_server(url: str, timeout: float = 10.0):
|
||||
"""Poll until the server responds."""
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
urlopen(url, timeout=1)
|
||||
return True
|
||||
except (URLError, OSError):
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
host = "127.0.0.1"
|
||||
port = 8765
|
||||
|
||||
# Start Django in a daemon thread
|
||||
server = threading.Thread(target=start_server, args=(host, port), daemon=True)
|
||||
server.start()
|
||||
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
if not wait_for_server(f"{base_url}/api/mizan/session/"):
|
||||
print("ERROR: Django server failed to start", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Backend running at {base_url}")
|
||||
|
||||
# Check if --headless flag is passed (for testing)
|
||||
if "--headless" in sys.argv:
|
||||
print("Headless mode — server running. Press Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return
|
||||
|
||||
# Open native window
|
||||
import webview
|
||||
|
||||
window = webview.create_window(
|
||||
title="mizan Desktop",
|
||||
url=base_url,
|
||||
width=1024,
|
||||
height=768,
|
||||
min_size=(640, 480),
|
||||
)
|
||||
webview.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
examples/django-react-desktop-app/backend/apps.py
Normal file
6
examples/django-react-desktop-app/backend/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DesktopBackendConfig(AppConfig):
|
||||
name = "backend"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
13
examples/django-react-desktop-app/backend/asgi.py
Normal file
13
examples/django-react-desktop-app/backend/asgi.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from mizan import wrap_asgi
|
||||
|
||||
import backend.mizan_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
419
examples/django-react-desktop-app/backend/djarea_clients.py
Normal file
419
examples/django-react-desktop-app/backend/djarea_clients.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Desktop RPC server functions.
|
||||
|
||||
Tests mizan's appropriateness for desktop apps:
|
||||
- Local file system access
|
||||
- SQLite CRUD
|
||||
- System introspection
|
||||
- Real-time channels (file watcher, app status)
|
||||
- No auth required (single-user desktop)
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client import client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.setup.registry import register
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System Info
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SystemInfoOutput(BaseModel):
|
||||
os_name: str
|
||||
os_version: str
|
||||
python_version: str
|
||||
hostname: str
|
||||
username: str
|
||||
home_dir: str
|
||||
cwd: str
|
||||
cpu_count: int
|
||||
mizan_version: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||
import mizan
|
||||
|
||||
return SystemInfoOutput(
|
||||
os_name=platform.system(),
|
||||
os_version=platform.version(),
|
||||
python_version=sys.version.split()[0],
|
||||
hostname=platform.node(),
|
||||
username=os.getenv("USER", os.getenv("USERNAME", "unknown")),
|
||||
home_dir=str(Path.home()),
|
||||
cwd=os.getcwd(),
|
||||
cpu_count=os.cpu_count() or 1,
|
||||
mizan_version=getattr(mizan, "__version__", "dev"),
|
||||
)
|
||||
|
||||
|
||||
register(system_info, "system_info")
|
||||
|
||||
|
||||
class DiskUsageOutput(BaseModel):
|
||||
path: str
|
||||
total_gb: float
|
||||
used_gb: float
|
||||
free_gb: float
|
||||
percent_used: float
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def disk_usage(request: HttpRequest, path: str = "/") -> DiskUsageOutput:
|
||||
usage = shutil.disk_usage(path)
|
||||
return DiskUsageOutput(
|
||||
path=path,
|
||||
total_gb=round(usage.total / (1024**3), 2),
|
||||
used_gb=round(usage.used / (1024**3), 2),
|
||||
free_gb=round(usage.free / (1024**3), 2),
|
||||
percent_used=round(usage.used / usage.total * 100, 1),
|
||||
)
|
||||
|
||||
|
||||
register(disk_usage, "disk_usage")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# File System
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FileEntry(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
is_dir: bool
|
||||
size: int
|
||||
modified: str
|
||||
|
||||
|
||||
class ListFilesOutput(BaseModel):
|
||||
directory: str
|
||||
entries: list[FileEntry]
|
||||
parent: str | None
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
||||
dir_path = Path(directory).expanduser().resolve()
|
||||
|
||||
if not dir_path.is_dir():
|
||||
raise ValueError(f"Not a directory: {dir_path}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in sorted(
|
||||
dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())
|
||||
):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except PermissionError:
|
||||
raise PermissionError(f"Cannot read directory: {dir_path}")
|
||||
|
||||
parent = str(dir_path.parent) if dir_path.parent != dir_path else None
|
||||
|
||||
return ListFilesOutput(
|
||||
directory=str(dir_path),
|
||||
entries=entries,
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
|
||||
register(list_files, "list_files")
|
||||
|
||||
|
||||
class FileContentOutput(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
size: int
|
||||
modified: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def read_file(request: HttpRequest, path: str) -> FileContentOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
if not file_path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
stat = file_path.stat()
|
||||
|
||||
# Safety: limit to 1MB text files
|
||||
if stat.st_size > 1_048_576:
|
||||
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError(f"Not a text file: {file_path}")
|
||||
|
||||
return FileContentOutput(
|
||||
path=str(file_path),
|
||||
content=content,
|
||||
size=stat.st_size,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
register(read_file, "read_file")
|
||||
|
||||
|
||||
class WriteFileOutput(BaseModel):
|
||||
path: str
|
||||
size: int
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
# Safety: only allow writing within home directory
|
||||
home = Path.home()
|
||||
if not str(file_path).startswith(str(home)):
|
||||
raise PermissionError(f"Can only write files within home directory: {home}")
|
||||
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
return WriteFileOutput(path=str(file_path), size=len(content.encode("utf-8")))
|
||||
|
||||
|
||||
register(write_file, "write_file")
|
||||
|
||||
|
||||
class DeleteFileOutput(BaseModel):
|
||||
path: str
|
||||
deleted: bool
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def delete_file(request: HttpRequest, path: str) -> DeleteFileOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
home = Path.home()
|
||||
if not str(file_path).startswith(str(home)):
|
||||
raise PermissionError(f"Can only delete files within home directory: {home}")
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return DeleteFileOutput(path=str(file_path), deleted=True)
|
||||
|
||||
return DeleteFileOutput(path=str(file_path), deleted=False)
|
||||
|
||||
|
||||
register(delete_file, "delete_file")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notes CRUD (SQLite)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class NoteOutput(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
pinned: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class NoteListOutput(BaseModel):
|
||||
notes: list[NoteOutput]
|
||||
count: int
|
||||
|
||||
|
||||
def _note_to_output(note) -> NoteOutput:
|
||||
return NoteOutput(
|
||||
id=note.id,
|
||||
title=note.title,
|
||||
content=note.content,
|
||||
pinned=note.pinned,
|
||||
created_at=note.created_at.isoformat(),
|
||||
updated_at=note.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def list_notes(request: HttpRequest) -> NoteListOutput:
|
||||
from backend.models import Note
|
||||
|
||||
notes = Note.objects.all()
|
||||
return NoteListOutput(
|
||||
notes=[_note_to_output(n) for n in notes],
|
||||
count=notes.count(),
|
||||
)
|
||||
|
||||
|
||||
register(list_notes, "list_notes")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def create_note(
|
||||
request: HttpRequest, title: str, content: str = "", pinned: bool = False
|
||||
) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(create_note, "create_note")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def get_note(request: HttpRequest, id: int) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
except Note.DoesNotExist:
|
||||
raise ValueError(f"Note {id} not found")
|
||||
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(get_note, "get_note")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def update_note(
|
||||
request: HttpRequest,
|
||||
id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
pinned: bool | None = None,
|
||||
) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
except Note.DoesNotExist:
|
||||
raise ValueError(f"Note {id} not found")
|
||||
|
||||
if title is not None:
|
||||
note.title = title
|
||||
if content is not None:
|
||||
note.content = content
|
||||
if pinned is not None:
|
||||
note.pinned = pinned
|
||||
|
||||
note.save()
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(update_note, "update_note")
|
||||
|
||||
|
||||
class DeleteNoteOutput(BaseModel):
|
||||
id: int
|
||||
deleted: bool
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
note.delete()
|
||||
return DeleteNoteOutput(id=id, deleted=True)
|
||||
except Note.DoesNotExist:
|
||||
return DeleteNoteOutput(id=id, deleted=False)
|
||||
|
||||
|
||||
register(delete_note, "delete_note")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channels — Real-time Desktop Events
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AppStatusChannel(ReactChannel):
|
||||
"""Push app status updates to the UI (uptime, memory, etc.)."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
uptime_seconds: float
|
||||
memory_mb: float
|
||||
note_count: int
|
||||
timestamp: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True # Desktop app, no auth needed
|
||||
|
||||
def group(self, params=None):
|
||||
return "app_status"
|
||||
|
||||
|
||||
register_channel(AppStatusChannel, "app_status")
|
||||
|
||||
|
||||
class NotesChannel(ReactChannel):
|
||||
"""Push notifications when notes are modified."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
action: str # "created", "updated", "deleted"
|
||||
note_id: int
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "notes_updates"
|
||||
|
||||
|
||||
register_channel(NotesChannel, "notes_updates")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# App Lifecycle
|
||||
# =============================================================================
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
class AppInfoOutput(BaseModel):
|
||||
app_name: str
|
||||
uptime_seconds: float
|
||||
db_path: str
|
||||
pid: int
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def app_info(request: HttpRequest) -> AppInfoOutput:
|
||||
from django.conf import settings
|
||||
|
||||
return AppInfoOutput(
|
||||
app_name="mizan Desktop",
|
||||
uptime_seconds=round(time.time() - _start_time, 2),
|
||||
db_path=str(settings.DATABASES["default"]["NAME"]),
|
||||
pid=os.getpid(),
|
||||
)
|
||||
|
||||
|
||||
register(app_info, "app_info")
|
||||
15
examples/django-react-desktop-app/backend/models.py
Normal file
15
examples/django-react-desktop-app/backend/models.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField(blank=True, default="")
|
||||
pinned = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-pinned", "-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
49
examples/django-react-desktop-app/backend/settings.py
Normal file
49
examples/django-react-desktop-app/backend/settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Django settings for the mizan desktop integration test app.
|
||||
|
||||
Runs entirely local: SQLite database, in-memory channel layer,
|
||||
no external services required.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SECRET_KEY = "desktop-app-local-only-secret-key"
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"backend",
|
||||
]
|
||||
|
||||
MIDDLEWARE = []
|
||||
|
||||
ROOT_URLCONF = "backend.urls"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": os.path.join(BASE_DIR, "app.db"),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
ASGI_APPLICATION = "backend.asgi.application"
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||
},
|
||||
}
|
||||
|
||||
# Serve the built frontend
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [os.path.join(BASE_DIR, "frontend", "dist")]
|
||||
|
||||
# No auth, no CSRF — local desktop app
|
||||
CSRF_COOKIE_HTTPONLY = False
|
||||
34
examples/django-react-desktop-app/backend/urls.py
Normal file
34
examples/django-react-desktop-app/backend/urls.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.urls import include, path, re_path
|
||||
from django.http import HttpResponse, HttpResponseNotFound
|
||||
from pathlib import Path
|
||||
|
||||
DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
|
||||
CONTENT_TYPES = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
".woff2": "font/woff2",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def serve_dist(request, path="index.html"):
|
||||
file_path = (DIST_DIR / path).resolve()
|
||||
|
||||
if not str(file_path).startswith(str(DIST_DIR)) or not file_path.is_file():
|
||||
return HttpResponseNotFound()
|
||||
|
||||
ct = CONTENT_TYPES.get(file_path.suffix, "application/octet-stream")
|
||||
return HttpResponse(file_path.read_bytes(), content_type=ct)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
re_path(r"^(?P<path>assets/.+)$", serve_dist),
|
||||
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
|
||||
path("", serve_dist),
|
||||
]
|
||||
16
examples/django-react-desktop-app/frontend/index.html
Normal file
16
examples/django-react-desktop-app/frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mizan Desktop</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
examples/django-react-desktop-app/frontend/package.json
Normal file
21
examples/django-react-desktop-app/frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mizan-desktop-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rythazhur/mizan": "file:../../react",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
215
examples/django-react-desktop-app/frontend/src/App.tsx
Normal file
215
examples/django-react-desktop-app/frontend/src/App.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
|
||||
|
||||
// ─── System Info ────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemInfo() {
|
||||
const { call } = useMizan()
|
||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
call('system_info').then(setInfo).catch(() => {})
|
||||
}, [call])
|
||||
|
||||
if (!info) return <div style={styles.card}>Loading system info...</div>
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>System</h2>
|
||||
<table style={styles.table}>
|
||||
<tbody>
|
||||
{Object.entries(info).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td style={styles.label}>{k}</td>
|
||||
<td style={styles.value}>{String(v)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Connection Status ──────────────────────────────────────────────────────
|
||||
|
||||
function StatusBar() {
|
||||
const status = useMizanStatus()
|
||||
return (
|
||||
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
||||
{status}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
||||
|
||||
function Notes() {
|
||||
const { call } = useMizan()
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [selected, setSelected] = useState<Note | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
call<{ notes: Note[] }>('list_notes').then(d => setNotes(d.notes)).catch(() => {})
|
||||
}, [call])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const create = async () => {
|
||||
if (!title.trim()) return
|
||||
await call('create_note', { title, content })
|
||||
setTitle('')
|
||||
setContent('')
|
||||
refresh()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return
|
||||
await call('update_note', { id: selected.id, title, content })
|
||||
setSelected(null)
|
||||
setTitle('')
|
||||
setContent('')
|
||||
refresh()
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
await call('delete_note', { id })
|
||||
if (selected?.id === id) { setSelected(null); setTitle(''); setContent('') }
|
||||
refresh()
|
||||
}
|
||||
|
||||
const select = (n: Note) => {
|
||||
setSelected(n)
|
||||
setTitle(n.title)
|
||||
setContent(n.content)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>Notes ({notes.length})</h2>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{notes.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => select(n)}
|
||||
style={{
|
||||
...styles.noteItem,
|
||||
borderLeft: selected?.id === n.id ? '3px solid #6cf' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span>{n.pinned ? '\u{1f4cc} ' : ''}{n.title}</span>
|
||||
<button onClick={e => { e.stopPropagation(); remove(n.id) }} style={styles.deleteBtn}>x</button>
|
||||
</div>
|
||||
))}
|
||||
{notes.length === 0 && <div style={{ color: '#666', padding: 8 }}>No notes yet</div>}
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
style={styles.input}
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={6}
|
||||
style={{ ...styles.input, resize: 'vertical' }}
|
||||
/>
|
||||
<button onClick={selected ? save : create} style={styles.btn}>
|
||||
{selected ? 'Save' : 'Create'}
|
||||
</button>
|
||||
{selected && (
|
||||
<button onClick={() => { setSelected(null); setTitle(''); setContent('') }} style={{ ...styles.btn, background: '#333' }}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── File Browser ───────────────────────────────────────────────────────────
|
||||
|
||||
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
||||
|
||||
function FileBrowser() {
|
||||
const { call } = useMizan()
|
||||
const [dir, setDir] = useState('~')
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
const [parent, setParent] = useState<string | null>(null)
|
||||
|
||||
const browse = useCallback((d: string) => {
|
||||
call<{ directory: string; entries: FileEntry[]; parent: string | null }>('list_files', { directory: d })
|
||||
.then(data => {
|
||||
setDir(data.directory)
|
||||
setEntries(data.entries.slice(0, 50))
|
||||
setParent(data.parent)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [call])
|
||||
|
||||
useEffect(() => { browse('~') }, [browse])
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>Files</h2>
|
||||
<div style={{ color: '#888', fontSize: 13, marginBottom: 8 }}>{dir}</div>
|
||||
{parent && (
|
||||
<div onClick={() => browse(parent)} style={{ ...styles.fileItem, color: '#6cf', cursor: 'pointer' }}>
|
||||
../ (parent)
|
||||
</div>
|
||||
)}
|
||||
{entries.map(e => (
|
||||
<div
|
||||
key={e.path}
|
||||
onClick={() => e.is_dir && browse(e.path)}
|
||||
style={{ ...styles.fileItem, cursor: e.is_dir ? 'pointer' : 'default', color: e.is_dir ? '#6cf' : '#ccc' }}
|
||||
>
|
||||
{e.is_dir ? '\u{1f4c1}' : '\u{1f4c4}'} {e.name}
|
||||
{!e.is_dir && <span style={{ color: '#666', marginLeft: 8 }}>{(e.size / 1024).toFixed(1)}K</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1>
|
||||
<StatusBar />
|
||||
</div>
|
||||
<SystemInfo />
|
||||
<Notes />
|
||||
<FileBrowser />
|
||||
</div>
|
||||
</MizanProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
card: { background: '#1a1a1a', borderRadius: 8, padding: 20, marginBottom: 16 },
|
||||
h2: { fontSize: 16, marginBottom: 12, color: '#aaa', textTransform: 'uppercase', letterSpacing: 1 },
|
||||
table: { width: '100%', fontSize: 14 },
|
||||
label: { padding: '4px 12px 4px 0', color: '#888', whiteSpace: 'nowrap' },
|
||||
value: { padding: '4px 0', wordBreak: 'break-all' },
|
||||
input: { width: '100%', padding: '8px 12px', marginBottom: 8, background: '#111', border: '1px solid #333', borderRadius: 4, color: '#e0e0e0', fontSize: 14 },
|
||||
btn: { padding: '8px 16px', background: '#2563eb', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', marginRight: 8, fontSize: 14 },
|
||||
noteItem: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', cursor: 'pointer', borderRadius: 4, marginBottom: 2 },
|
||||
deleteBtn: { background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 14, padding: '2px 6px' },
|
||||
fileItem: { padding: '4px 8px', fontSize: 14 },
|
||||
statusBar: { fontSize: 12, fontFamily: 'monospace' },
|
||||
}
|
||||
4
examples/django-react-desktop-app/frontend/src/main.tsx
Normal file
4
examples/django-react-desktop-app/frontend/src/main.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
11
examples/django-react-desktop-app/frontend/tsconfig.json
Normal file
11
examples/django-react-desktop-app/frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
examples/django-react-desktop-app/frontend/vite.config.ts
Normal file
12
examples/django-react-desktop-app/frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8765',
|
||||
'/ws': { target: 'ws://127.0.0.1:8765', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
8
examples/django-react-desktop-app/manage.py
Normal file
8
examples/django-react-desktop-app/manage.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(sys.argv)
|
||||
25
examples/django-react-desktop-app/pyproject.toml
Normal file
25
examples/django-react-desktop-app/pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "mizan-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Desktop integration test app for mizan"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mizan[channels]",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"pywebview[qt]>=5.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
mizan = { path = "../django", editable = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-django>=4.9",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "backend.settings"
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
0
examples/django-react-desktop-app/tests/__init__.py
Normal file
0
examples/django-react-desktop-app/tests/__init__.py
Normal file
8
examples/django-react-desktop-app/tests/conftest.py
Normal file
8
examples/django-react-desktop-app/tests/conftest.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
# Ensure migrations run before tests
|
||||
def pytest_configure():
|
||||
# Import mizan_clients to trigger function registration
|
||||
import backend.mizan_clients # noqa: F401
|
||||
179
examples/django-react-desktop-app/tests/test_desktop_rpc.py
Normal file
179
examples/django-react-desktop-app/tests/test_desktop_rpc.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
REAL integration tests for the mizan RPC framework layer.
|
||||
|
||||
Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
|
||||
Every test makes a real HTTP request — no mocks, no RequestFactory.
|
||||
"""
|
||||
|
||||
import json
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from django.test import LiveServerTestCase
|
||||
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
if "csrftoken=" in cookie:
|
||||
self._csrf_token = cookie.split("csrftoken=")[1].split(";")[0]
|
||||
self._cookies = f"csrftoken={self._csrf_token}"
|
||||
return
|
||||
self._csrf_token = None
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
if self._cookies:
|
||||
req.add_header("Cookie", self._cookies)
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
def _raw_post(
|
||||
self,
|
||||
path: str,
|
||||
body: bytes | str,
|
||||
content_type: str = "application/json",
|
||||
include_csrf: bool = False,
|
||||
):
|
||||
"""Raw POST without the call() envelope — for testing malformed requests."""
|
||||
url = f"{self.live_server_url}{path}"
|
||||
if isinstance(body, str):
|
||||
body = body.encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", content_type)
|
||||
if include_csrf and self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
req.add_header("Cookie", self._cookies)
|
||||
return urlopen(req)
|
||||
|
||||
|
||||
class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""CSRF handling over real HTTP — the thing that was broken."""
|
||||
|
||||
def test_session_endpoint_sets_csrf_cookie(self):
|
||||
"""GET /session/ must return a Set-Cookie with csrftoken."""
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
|
||||
csrf_cookies = [c for c in cookies if "csrftoken=" in c]
|
||||
self.assertGreater(len(csrf_cookies), 0, "No csrftoken cookie set by /session/")
|
||||
|
||||
def test_call_without_csrf_is_rejected(self):
|
||||
"""POST /call/ without CSRF token must fail."""
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": "system_info", "args": {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
resp = urlopen(req)
|
||||
data = json.loads(resp.read())
|
||||
# If it doesn't raise, the response should indicate an error
|
||||
self.assertTrue(data.get("error"), "POST without CSRF should be rejected")
|
||||
except HTTPError as e:
|
||||
self.assertEqual(e.code, 403, f"Expected 403, got {e.code}")
|
||||
|
||||
def test_call_with_csrf_succeeds(self):
|
||||
"""POST /call/ with valid CSRF token must work."""
|
||||
self._session_init()
|
||||
data = self._call("system_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertIn("os_name", data["data"])
|
||||
|
||||
|
||||
class ValidationTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""Pydantic validation errors over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_missing_required_field(self):
|
||||
"""Calling create_note without title should return VALIDATION_ERROR."""
|
||||
data = self._call("create_note", {})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "VALIDATION_ERROR")
|
||||
|
||||
def test_wrong_type(self):
|
||||
"""Calling delete_note with string id should return VALIDATION_ERROR."""
|
||||
data = self._call("delete_note", {"id": "not-an-int"})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "VALIDATION_ERROR")
|
||||
|
||||
def test_missing_multiple_fields(self):
|
||||
"""write_file with no args should list all missing fields."""
|
||||
data = self._call("write_file", {})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "VALIDATION_ERROR")
|
||||
|
||||
|
||||
class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""Error codes over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_not_found_function(self):
|
||||
data = self._call("this_does_not_exist")
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "NOT_FOUND")
|
||||
|
||||
def test_forbidden_write_outside_home(self):
|
||||
data = self._call("write_file", {"path": "/etc/nope.txt", "content": "x"})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "FORBIDDEN")
|
||||
|
||||
def test_get_method_rejected(self):
|
||||
"""GET to /call/ should be rejected."""
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
try:
|
||||
resp = urlopen(Request(url))
|
||||
data = json.loads(resp.read())
|
||||
self.assertTrue(data.get("error"))
|
||||
except HTTPError as e:
|
||||
self.assertIn(e.code, [403, 405])
|
||||
|
||||
def test_invalid_json_body(self):
|
||||
"""Malformed JSON should return BAD_REQUEST."""
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/mizan/call/",
|
||||
body="not valid json{{{",
|
||||
include_csrf=True,
|
||||
)
|
||||
data = json.loads(resp.read())
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "BAD_REQUEST")
|
||||
except HTTPError as e:
|
||||
self.assertIn(e.code, [400, 403])
|
||||
|
||||
def test_missing_fn_field(self):
|
||||
"""POST with valid JSON but no 'fn' field should return BAD_REQUEST."""
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/mizan/call/",
|
||||
body=json.dumps({"not_fn": "hello"}),
|
||||
include_csrf=True,
|
||||
)
|
||||
data = json.loads(resp.read())
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "BAD_REQUEST")
|
||||
except HTTPError as e:
|
||||
self.assertIn(e.code, [400, 403])
|
||||
143
examples/django-react-desktop-app/tests/test_notes.py
Normal file
143
examples/django-react-desktop-app/tests/test_notes.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
REAL integration tests for notes CRUD over HTTP.
|
||||
|
||||
Every test makes actual HTTP requests to a live Django server.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import LiveServerTestCase
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
if "csrftoken=" in cookie:
|
||||
self._csrf_token = cookie.split("csrftoken=")[1].split(";")[0]
|
||||
self._cookies = f"csrftoken={self._csrf_token}"
|
||||
return
|
||||
self._csrf_token = None
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
if self._cookies:
|
||||
req.add_header("Cookie", self._cookies)
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
class NotesCRUDTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""Full CRUD lifecycle over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_list_notes_empty(self):
|
||||
data = self._call("list_notes")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["notes"], [])
|
||||
self.assertEqual(data["data"]["count"], 0)
|
||||
|
||||
def test_create_note(self):
|
||||
data = self._call("create_note", {"title": "First Note", "content": "Hello!"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["title"], "First Note")
|
||||
self.assertEqual(data["data"]["content"], "Hello!")
|
||||
self.assertFalse(data["data"]["pinned"])
|
||||
self.assertIn("id", data["data"])
|
||||
self.assertIn("created_at", data["data"])
|
||||
|
||||
def test_create_and_list(self):
|
||||
self._call("create_note", {"title": "Note A"})
|
||||
self._call("create_note", {"title": "Note B"})
|
||||
|
||||
data = self._call("list_notes")
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["count"], 2)
|
||||
titles = [n["title"] for n in data["data"]["notes"]]
|
||||
self.assertIn("Note A", titles)
|
||||
self.assertIn("Note B", titles)
|
||||
|
||||
def test_get_note_by_id(self):
|
||||
create = self._call("create_note", {"title": "Get Me", "content": "Specific"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("get_note", {"id": note_id})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["id"], note_id)
|
||||
self.assertEqual(data["data"]["title"], "Get Me")
|
||||
|
||||
def test_update_note(self):
|
||||
create = self._call("create_note", {"title": "Original"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("update_note", {"id": note_id, "title": "Updated"})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["title"], "Updated")
|
||||
|
||||
def test_update_note_pin(self):
|
||||
create = self._call("create_note", {"title": "Pin Me"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("update_note", {"id": note_id, "pinned": True})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertTrue(data["data"]["pinned"])
|
||||
|
||||
def test_delete_note(self):
|
||||
create = self._call("create_note", {"title": "Delete Me"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("delete_note", {"id": note_id})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertTrue(data["data"]["deleted"])
|
||||
|
||||
# Verify it's gone
|
||||
from urllib.error import HTTPError
|
||||
|
||||
try:
|
||||
get_data = self._call("get_note", {"id": note_id})
|
||||
self.assertTrue(get_data["error"])
|
||||
except HTTPError:
|
||||
pass # 500 is also a valid failure signal
|
||||
|
||||
def test_pinned_notes_sort_first(self):
|
||||
self._call("create_note", {"title": "Unpinned"})
|
||||
self._call("create_note", {"title": "Pinned", "pinned": True})
|
||||
|
||||
data = self._call("list_notes")
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["notes"][0]["title"], "Pinned")
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Create -> update -> pin -> verify -> delete over real HTTP."""
|
||||
# Create
|
||||
create = self._call("create_note", {"title": "Lifecycle", "content": "v1"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
# Update
|
||||
self._call("update_note", {"id": note_id, "content": "v2"})
|
||||
|
||||
# Pin
|
||||
self._call("update_note", {"id": note_id, "pinned": True})
|
||||
|
||||
# Verify
|
||||
get = self._call("get_note", {"id": note_id})
|
||||
self.assertEqual(get["data"]["title"], "Lifecycle")
|
||||
self.assertEqual(get["data"]["content"], "v2")
|
||||
self.assertTrue(get["data"]["pinned"])
|
||||
|
||||
# Delete
|
||||
delete = self._call("delete_note", {"id": note_id})
|
||||
self.assertTrue(delete["data"]["deleted"])
|
||||
167
examples/django-react-desktop-app/tests/test_system.py
Normal file
167
examples/django-react-desktop-app/tests/test_system.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
REAL integration tests for desktop system RPC functions.
|
||||
|
||||
These make actual HTTP requests to a running Django server.
|
||||
No RequestFactory, no mocks, no shortcuts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import LiveServerTestCase
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
|
||||
class RealHTTPMixin:
|
||||
"""Makes real HTTP requests to the live server."""
|
||||
|
||||
def _session_init(self):
|
||||
"""Hit /session/ to get CSRF cookie, like mizanProvider does."""
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
req = Request(url)
|
||||
resp = urlopen(req)
|
||||
# Extract csrftoken from Set-Cookie header
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
if "csrftoken=" in cookie:
|
||||
self._csrf_token = cookie.split("csrftoken=")[1].split(";")[0]
|
||||
self._cookies = f"csrftoken={self._csrf_token}"
|
||||
return
|
||||
self._csrf_token = None
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
"""Make a real POST to /api/mizan/call/ with CSRF token."""
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
if self._cookies:
|
||||
req.add_header("Cookie", self._cookies)
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
class SystemInfoTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""system_info over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_system_info_returns_os_data(self):
|
||||
data = self._call("system_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["os_name"], platform.system())
|
||||
self.assertEqual(data["data"]["hostname"], platform.node())
|
||||
self.assertGreater(data["data"]["cpu_count"], 0)
|
||||
|
||||
def test_system_info_returns_paths(self):
|
||||
data = self._call("system_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["home_dir"], str(Path.home()))
|
||||
self.assertEqual(data["data"]["cwd"], os.getcwd())
|
||||
|
||||
def test_disk_usage(self):
|
||||
data = self._call("disk_usage", {"path": "/"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertGreater(data["data"]["total_gb"], 0)
|
||||
self.assertGreater(data["data"]["free_gb"], 0)
|
||||
self.assertGreaterEqual(data["data"]["percent_used"], 0)
|
||||
self.assertLessEqual(data["data"]["percent_used"], 100)
|
||||
|
||||
def test_app_info(self):
|
||||
data = self._call("app_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["app_name"], "mizan Desktop")
|
||||
self.assertGreater(data["data"]["uptime_seconds"], 0)
|
||||
|
||||
|
||||
class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""File system RPC over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
self.test_dir = Path.home() / ".mizan-test"
|
||||
self.test_dir.mkdir(exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
if self.test_dir.exists():
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_list_files_home(self):
|
||||
data = self._call("list_files", {"directory": "~"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["directory"], str(Path.home()))
|
||||
self.assertIsInstance(data["data"]["entries"], list)
|
||||
|
||||
def test_list_files_root_has_no_parent(self):
|
||||
data = self._call("list_files", {"directory": "/"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertIsNone(data["data"]["parent"])
|
||||
|
||||
def test_write_and_read_file(self):
|
||||
"""Full round-trip over real HTTP: write, read back, verify."""
|
||||
test_path = str(self.test_dir / "test-note.txt")
|
||||
test_content = "Hello from a REAL HTTP integration test!"
|
||||
|
||||
# Write
|
||||
write_data = self._call(
|
||||
"write_file", {"path": test_path, "content": test_content}
|
||||
)
|
||||
self.assertFalse(write_data["error"])
|
||||
self.assertEqual(write_data["data"]["path"], test_path)
|
||||
|
||||
# Read back
|
||||
read_data = self._call("read_file", {"path": test_path})
|
||||
self.assertFalse(read_data["error"])
|
||||
self.assertEqual(read_data["data"]["content"], test_content)
|
||||
|
||||
def test_write_outside_home_rejected(self):
|
||||
"""Server should reject writes outside home directory."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
try:
|
||||
data = self._call(
|
||||
"write_file", {"path": "/tmp/escape.txt", "content": "nope"}
|
||||
)
|
||||
# If we get here, check the response has an error
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "FORBIDDEN")
|
||||
except HTTPError as e:
|
||||
# 403 is also acceptable
|
||||
self.assertEqual(e.code, 403)
|
||||
|
||||
def test_delete_file(self):
|
||||
test_path = str(self.test_dir / "to-delete.txt")
|
||||
(self.test_dir / "to-delete.txt").write_text("delete me")
|
||||
|
||||
data = self._call("delete_file", {"path": test_path})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertTrue(data["data"]["deleted"])
|
||||
self.assertFalse(Path(test_path).exists())
|
||||
|
||||
def test_file_entries_have_metadata(self):
|
||||
(self.test_dir / "metadata-test.txt").write_text("hello")
|
||||
|
||||
data = self._call("list_files", {"directory": str(self.test_dir)})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertGreater(len(data["data"]["entries"]), 0)
|
||||
|
||||
entry = data["data"]["entries"][0]
|
||||
self.assertIn("name", entry)
|
||||
self.assertIn("path", entry)
|
||||
self.assertIn("is_dir", entry)
|
||||
self.assertIn("size", entry)
|
||||
self.assertIn("modified", entry)
|
||||
10
examples/django-react-site/backend/manage.py
Normal file
10
examples/django-react-site/backend/manage.py
Normal 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)
|
||||
9
examples/django-react-site/backend/testapp/apps.py
Normal file
9
examples/django-react-site/backend/testapp/apps.py
Normal 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
|
||||
14
examples/django-react-site/backend/testapp/asgi.py
Normal file
14
examples/django-react-site/backend/testapp/asgi.py
Normal 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())
|
||||
411
examples/django-react-site/backend/testapp/djarea_clients.py
Normal file
411
examples/django-react-site/backend/testapp/djarea_clients.py
Normal 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")
|
||||
29
examples/django-react-site/backend/testapp/models.py
Normal file
29
examples/django-react-site/backend/testapp/models.py
Normal 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 = []
|
||||
76
examples/django-react-site/backend/testapp/settings.py
Normal file
76
examples/django-react-site/backend/testapp/settings.py
Normal 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",
|
||||
]
|
||||
5
examples/django-react-site/backend/testapp/urls.py
Normal file
5
examples/django-react-site/backend/testapp/urls.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
186
examples/django-react-site/djarea.spec.ts
Normal file
186
examples/django-react-site/djarea.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
22
examples/django-react-site/harness/django.config.mjs
Normal file
22
examples/django-react-site/harness/django.config.mjs
Normal 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',
|
||||
}
|
||||
5
examples/django-react-site/harness/index.html
Normal file
5
examples/django-react-site/harness/index.html
Normal 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>
|
||||
22
examples/django-react-site/harness/package.json
Normal file
22
examples/django-react-site/harness/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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', {})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
337
examples/django-react-site/harness/src/api/generated.channels.ts
Normal file
337
examples/django-react-site/harness/src/api/generated.channels.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
257
examples/django-react-site/harness/src/api/generated.django.tsx
Normal file
257
examples/django-react-site/harness/src/api/generated.django.tsx
Normal 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
2123
examples/django-react-site/harness/src/api/generated.djarea.ts
Normal file
2123
examples/django-react-site/harness/src/api/generated.djarea.ts
Normal file
File diff suppressed because it is too large
Load Diff
226
examples/django-react-site/harness/src/api/generated.forms.ts
Normal file
226
examples/django-react-site/harness/src/api/generated.forms.ts
Normal 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
|
||||
90
examples/django-react-site/harness/src/api/index.ts
Normal file
90
examples/django-react-site/harness/src/api/index.ts
Normal 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'
|
||||
264
examples/django-react-site/harness/src/fixtures.tsx
Normal file
264
examples/django-react-site/harness/src/fixtures.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
examples/django-react-site/harness/src/main.tsx
Normal file
13
examples/django-react-site/harness/src/main.tsx
Normal 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 />)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
11
examples/django-react-site/harness/tsconfig.json
Normal file
11
examples/django-react-site/harness/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
30
examples/django-react-site/harness/vite.config.ts
Normal file
30
examples/django-react-site/harness/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user