A channel's message slots are named from the client, on every backend

The IR called them react-message and django-message, so a FastAPI channel had
to declare a DjangoMessage. They are client-message and server-message now,
and the direction words hold wherever a channel is declared: Params /
ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and
friends so no backend names a type itself. Django's ReactChannel and
FastAPI's ReactChannel are both Channel.

mizan-fastapi never registered a channels extension, so build_ir() emitted no
channel at all and every payload type was invisible to codegen. It registers
one now. RegistryExtension is an ABC requiring all(), which is what the IR
reads — an extension that cannot enumerate its registrations no longer exists.

The gate that should have caught the rename could not: tests/afi registered no
channel because mizan-rust had no channel registry to register one in, so a
five-package rename of the wire contract passed byte-parity without a channel
byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a
#[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's
split; the AFI fixture now carries a channel with every slot and one with a
single slot, so all three backends prove the contract byte for byte.

MizanChannel held three Option<String> beside three has_*() predicates and
unwrapped them with defaults; it holds an ordered slot vector, so an absent
slot is absent rather than defaulted. The channels target emitted a React
hooks file that a stage1-only consumer could not compile — react emits that
now. The codegen's parity tests byte-compared emitted source against baselines
without ever compiling it: they compile the generated crate and run its tests,
import the generated Python package and call every method, and typecheck each
TypeScript target against a consumer.

Also fixed at source: app_visitor printed its import diagnostic to stdout, the
stream export_mizan_ir writes KDL to, so a failed import silently corrupted the
IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on
any non-JSON-serializable field default; Django and mizan-core derived Pascal
names two different ways, disagreeing on every dotted channel name.

ir.py builds a document and renders templates/ir/document.kdl.j2 rather than
appending KDL strings with hand-tracked indentation, and named types resolve to
a fixed point — a model reachable only through a union branch was referenced by
a ref that no type block ever defined.

The rest is the write-gate's own classifiers run over the standing tree:
relative imports, silent swallows, Protocol contracts that should be ABCs,
emitters hand-rendering target source, catch-all arms over closed enums, and
comments narrating the project rather than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,10 +1,5 @@
#!/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.
"""
"""Launcher: runs the Django ASGI server locally and opens it in a native window."""
import os
import sys
@@ -13,19 +8,18 @@ import time
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
# Work around Qt WebEngine GPU crashes on some systems
# Qt WebEngine crashes on some GPU/driver combinations unless it renders on CPU.
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
# --run-syncdb builds the tables on a first launch with no migration files.
call_command("migrate", "--run-syncdb", verbosity=0)
import uvicorn
@@ -38,51 +32,49 @@ def start_server(host: str, port: int):
)
def wait_for_server(url: str, timeout: float = 10.0):
"""Poll until the server responds."""
def wait_for_server(url: str, timeout: float = 10.0) -> None:
"""Poll until the server answers, or raise carrying the last refusal."""
from urllib.request import urlopen
from urllib.error import URLError
deadline = time.time() + timeout
last_error: OSError | None = None
while time.time() < deadline:
try:
urlopen(url, timeout=1)
return True
except (URLError, OSError):
return
except OSError as e:
last_error = e
time.sleep(0.1)
return False
raise TimeoutError(
f"Django server did not answer {url} within {timeout}s"
) from last_error
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)
wait_for_server(f"{base_url}/api/mizan/session/")
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
print("Interrupted — stopping the server.", file=sys.stderr)
return
# Open native window
import webview
window = webview.create_window(
webview.create_window(
title="mizan Desktop",
url=base_url,
width=1024,

View File

@@ -1,14 +1,4 @@
"""
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 logging
import os
import platform
import shutil
@@ -21,11 +11,14 @@ 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 Channel
from mizan.setup import register
from mizan.channels import register as register_channel
logger = logging.getLogger(__name__)
# =============================================================================
# System Info
# =============================================================================
@@ -119,17 +112,21 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
):
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):
except (PermissionError, OSError) as e:
# A broken symlink or an unreadable entry drops out of the
# listing rather than failing the whole directory.
logger.warning("Skipping %s: %s", entry, e)
continue
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:
raise PermissionError(f"Cannot read directory: {dir_path}")
@@ -161,7 +158,7 @@ def read_file(request: HttpRequest, path: str) -> FileContentOutput:
stat = file_path.stat()
# Safety: limit to 1MB text files
# Reads are capped at 1MB so a large binary cannot be pulled into memory.
if stat.st_size > 1_048_576:
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
@@ -190,7 +187,6 @@ class WriteFileOutput(BaseModel):
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}")
@@ -339,11 +335,14 @@ def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
try:
note = Note.objects.get(pk=id)
note.delete()
return DeleteNoteOutput(id=id, deleted=True)
except Note.DoesNotExist:
# Deleting an absent note is reported, not raised.
logger.info("delete_note: note %s is already absent", id)
return DeleteNoteOutput(id=id, deleted=False)
note.delete()
return DeleteNoteOutput(id=id, deleted=True)
register(delete_note, "delete_note")
@@ -353,17 +352,18 @@ register(delete_note, "delete_note")
# =============================================================================
class AppStatusChannel(ReactChannel):
class AppStatusChannel(Channel):
"""Push app status updates to the UI (uptime, memory, etc.)."""
class DjangoMessage(BaseModel):
class ServerMessage(BaseModel):
uptime_seconds: float
memory_mb: float
note_count: int
timestamp: str
def authorize(self, params=None):
return True # Desktop app, no auth needed
# One local user owns the whole process; there is no identity to check.
return True
def group(self, params=None):
return "app_status"
@@ -372,15 +372,16 @@ class AppStatusChannel(ReactChannel):
register_channel(AppStatusChannel, "app_status")
class NotesChannel(ReactChannel):
class NotesChannel(Channel):
"""Push notifications when notes are modified."""
class DjangoMessage(BaseModel):
class ServerMessage(BaseModel):
action: str # "created", "updated", "deleted"
note_id: int
title: str
def authorize(self, params=None):
# One local user owns the whole process; there is no identity to check.
return True
def group(self, params=None):

View File

@@ -4,10 +4,6 @@
<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>

View File

@@ -12,9 +12,11 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}

View File

@@ -1,47 +1,46 @@
import { useState, useEffect, useCallback } from 'react'
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
// ─── System Info ────────────────────────────────────────────────────────────
type SystemFacts = Record<string, unknown>
type NoteList = { notes: Note[] }
type FileListing = { directory: string; entries: FileEntry[]; parent: string | null }
function SystemInfo() {
const { call } = useMizan()
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
const [info, setInfo] = useState<SystemFacts | null>(null)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
call('system_info').then(setInfo).catch(() => {})
call<undefined, SystemFacts>('system_info').then(setInfo).catch(setError)
}, [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 className="panel p-5 mb-4">
<h2 className="panel-heading mb-3">System</h2>
{error && <div className="load-error">{error.message}</div>}
{!info && !error && <div className="muted">Loading system info...</div>}
{info && (
<table className="info-table w-full">
<tbody>
{Object.entries(info).map(([k, v]) => (
<tr key={k}>
<td className="info-key py-1 pr-3 whitespace-nowrap">{k}</td>
<td className="py-1 break-all">{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>
)
}
const tone = status === 'connected' ? 'status-bar--online' : 'status-bar--offline'
// ─── Notes ──────────────────────────────────────────────────────────────────
return <div className={`status-bar ${tone}`}>{status}</div>
}
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
@@ -51,34 +50,41 @@ function Notes() {
const [selected, setSelected] = useState<Note | null>(null)
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [error, setError] = useState<Error | null>(null)
const refresh = useCallback(() => {
call<{ notes: Note[] }>('list_notes').then(d => setNotes(d.notes)).catch(() => {})
call<undefined, NoteList>('list_notes').then(d => setNotes(d.notes)).catch(setError)
}, [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 })
const clearDraft = () => {
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 create = () => {
if (!title.trim()) return
call('create_note', { title, content })
.then(() => { clearDraft(); refresh() })
.catch(setError)
}
const save = () => {
if (!selected) return
call('update_note', { id: selected.id, title, content })
.then(() => { clearDraft(); refresh() })
.catch(setError)
}
const remove = (id: number) => {
call('delete_note', { id })
.then(() => {
if (selected?.id === id) clearDraft()
refresh()
})
.catch(setError)
}
const select = (n: Note) => {
@@ -88,44 +94,50 @@ function Notes() {
}
return (
<div style={styles.card}>
<h2 style={styles.h2}>Notes ({notes.length})</h2>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<div className="panel p-5 mb-4">
<h2 className="panel-heading mb-3">Notes ({notes.length})</h2>
{error && <div className="load-error mb-2">{error.message}</div>}
<div className="flex gap-3">
<div className="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',
}}
className={
'note-item flex items-center justify-between py-2 px-3 mb-0.5 ' +
(selected?.id === n.id ? 'note-item--selected' : '')
}
>
<span>{n.pinned ? '\u{1f4cc} ' : ''}{n.title}</span>
<button onClick={e => { e.stopPropagation(); remove(n.id) }} style={styles.deleteBtn}>x</button>
<button
onClick={e => { e.stopPropagation(); remove(n.id) }}
className="icon-btn py-0.5 px-1.5"
>
x
</button>
</div>
))}
{notes.length === 0 && <div style={{ color: '#666', padding: 8 }}>No notes yet</div>}
{notes.length === 0 && <div className="muted p-2">No notes yet</div>}
</div>
<div style={{ flex: 2 }}>
<div className="flex-[2]">
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Title"
style={styles.input}
className="field w-full py-2 px-3 mb-2"
/>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="Content"
rows={6}
style={{ ...styles.input, resize: 'vertical' }}
className="field w-full py-2 px-3 mb-2 resize-y"
/>
<button onClick={selected ? save : create} style={styles.btn}>
<button onClick={selected ? save : create} className="btn py-2 px-4 mr-2">
{selected ? 'Save' : 'Create'}
</button>
{selected && (
<button onClick={() => { setSelected(null); setTitle(''); setContent('') }} style={{ ...styles.btn, background: '#333' }}>
<button onClick={clearDraft} className="btn btn--muted py-2 px-4 mr-2">
Cancel
</button>
)}
@@ -135,8 +147,6 @@ function Notes() {
)
}
// ─── File Browser ───────────────────────────────────────────────────────────
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
function FileBrowser() {
@@ -144,25 +154,27 @@ function FileBrowser() {
const [dir, setDir] = useState('~')
const [entries, setEntries] = useState<FileEntry[]>([])
const [parent, setParent] = useState<string | null>(null)
const [error, setError] = useState<Error | null>(null)
const browse = useCallback((d: string) => {
call<{ directory: string; entries: FileEntry[]; parent: string | null }>('list_files', { directory: d })
call<{ directory: string }, FileListing>('list_files', { directory: d })
.then(data => {
setDir(data.directory)
setEntries(data.entries.slice(0, 50))
setParent(data.parent)
})
.catch(() => {})
.catch(setError)
}, [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>
<div className="panel p-5 mb-4">
<h2 className="panel-heading mb-3">Files</h2>
{error && <div className="load-error mb-2">{error.message}</div>}
<div className="path-line mb-2">{dir}</div>
{parent && (
<div onClick={() => browse(parent)} style={{ ...styles.fileItem, color: '#6cf', cursor: 'pointer' }}>
<div onClick={() => browse(parent)} className="file-item file-item--dir py-1 px-2">
../ (parent)
</div>
)}
@@ -170,24 +182,22 @@ function FileBrowser() {
<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' }}
className={'file-item py-1 px-2 ' + (e.is_dir ? 'file-item--dir' : 'file-item--file')}
>
{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>}
{!e.is_dir && <span className="file-size ml-2">{(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>
<div className="max-w-[960px] mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="app-title">mizan Desktop</h1>
<StatusBar />
</div>
<SystemInfo />
@@ -197,19 +207,3 @@ export function App() {
</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' },
}

View File

@@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(<App />)

View File

@@ -0,0 +1,115 @@
@import "tailwindcss";
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
}
.app-title {
font-size: 24px;
color: #fff;
}
.status-bar {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, monospace;
}
.status-bar--online {
color: #4ade80;
}
.status-bar--offline {
color: #f87171;
}
.panel {
background: #1a1a1a;
border-radius: 8px;
}
.panel-heading {
font-size: 16px;
color: #aaa;
text-transform: uppercase;
letter-spacing: 1px;
}
.info-table {
font-size: 14px;
}
.info-key {
color: #888;
}
.muted {
color: #666;
}
.load-error {
color: #f87171;
font-size: 14px;
}
.note-item {
border-radius: 4px;
border-left: 3px solid transparent;
cursor: pointer;
}
.note-item--selected {
border-left-color: #6cf;
}
.field {
background: #111;
border: 1px solid #333;
border-radius: 4px;
color: #e0e0e0;
font-size: 14px;
}
.btn {
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn--muted {
background: #333;
}
.icon-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 14px;
}
.path-line {
color: #888;
font-size: 13px;
}
.file-item {
font-size: 14px;
}
.file-item--dir {
color: #6cf;
cursor: pointer;
}
.file-item--file {
color: #ccc;
}
.file-size {
color: #666;
}

View File

@@ -1,11 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
const reactPkg = path.resolve(__dirname, '../../../frontends/mizan-react/src')
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),

View File

@@ -10,7 +10,7 @@ dependencies = [
]
[tool.uv.sources]
mizan = { path = "../django", editable = true }
mizan = { path = "../../backends/mizan-django", editable = true }
[project.optional-dependencies]
dev = [

View File

@@ -1,8 +1,4 @@
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
# Imported for its side effect: module-level register() calls populate the
# mizan function registry the RPC endpoint dispatches against.
import backend.clients # noqa: F401

View File

@@ -0,0 +1,81 @@
import json
from dataclasses import dataclass
from typing import Any
from urllib.request import HTTPErrorProcessor, Request, build_opener, urlopen
class _KeepErrorResponses(HTTPErrorProcessor):
# urllib's default processor converts every non-2xx into a raised
# HTTPError. The RPC endpoint carries its error envelope in the body of a
# 4xx/5xx, so the response object has to reach the caller intact.
def http_response(self, request, response):
return response
https_response = http_response
_opener = build_opener(_KeepErrorResponses)
@dataclass(frozen=True)
class Reply:
status: int
raw: bytes
@property
def body(self) -> dict[str, Any]:
# Django's own CSRF and 500 pages are HTML, so decoding is deferred to
# the tests that actually assert on the mizan envelope.
return json.loads(self.raw)
def _send(req: Request) -> Reply:
resp = _opener.open(req)
return Reply(status=resp.status, raw=resp.read())
class LiveRPCMixin:
"""HTTP access to the mizan endpoints on a LiveServerTestCase server."""
csrf_token: str = ""
cookies: str = ""
def session_init(self) -> None:
url = f"{self.live_server_url}/api/mizan/session/"
resp = urlopen(Request(url))
for cookie in resp.headers.get_all("Set-Cookie") or []:
if "csrftoken=" in cookie:
self.csrf_token = cookie.split("csrftoken=")[1].split(";")[0]
self.cookies = f"csrftoken={self.csrf_token}"
return
self.csrf_token = ""
self.cookies = ""
def get(self, path: str) -> Reply:
return _send(Request(f"{self.live_server_url}{path}"))
def post(
self,
path: str,
body: bytes | str,
content_type: str = "application/json",
with_csrf: bool = True,
) -> Reply:
if isinstance(body, str):
body = body.encode()
req = Request(f"{self.live_server_url}{path}", data=body, method="POST")
req.add_header("Content-Type", content_type)
if with_csrf and self.csrf_token:
req.add_header("X-CSRFToken", self.csrf_token)
req.add_header("Cookie", self.cookies)
return _send(req)
def call(self, fn: str, args: dict | None = None, with_csrf: bool = True) -> Reply:
payload = json.dumps({"fn": fn, "args": args or {}})
return self.post("/api/mizan/call/", payload, with_csrf=with_csrf)
def result(self, fn: str, args: dict | None = None) -> Any:
"""The `result` payload of a call that must have succeeded."""
reply = self.call(fn, args)
self.assertEqual(reply.status, 200, reply.raw)
return reply.body["result"]

View File

@@ -1,66 +1,16 @@
"""
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.
"""
"""The RPC transport itself over HTTP: CSRF, method guards, error envelopes."""
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)
from tests.live_http import LiveRPCMixin
class CSRFTests(RealHTTPMixin, LiveServerTestCase):
"""CSRF handling over real HTTP — the thing that was broken."""
class CSRFTests(LiveRPCMixin, LiveServerTestCase):
def test_session_endpoint_sets_csrf_cookie(self):
"""GET /session/ must return a Set-Cookie with csrftoken."""
from urllib.request import Request, urlopen
url = f"{self.live_server_url}/api/mizan/session/"
resp = urlopen(Request(url))
cookies = resp.headers.get_all("Set-Cookie") or []
@@ -69,111 +19,79 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
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")
reply = self.call("system_info", with_csrf=False)
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}")
self.assertEqual(reply.status, 403)
def test_call_with_csrf_succeeds(self):
"""POST /call/ with valid CSRF token must work."""
self._session_init()
data = self._call("system_info")
self.session_init()
self.assertFalse(data["error"])
self.assertIn("os_name", data["data"])
self.assertIn("os_name", self.result("system_info"))
class ValidationTests(RealHTTPMixin, LiveServerTestCase):
"""Pydantic validation errors over real HTTP."""
class ValidationTests(LiveRPCMixin, LiveServerTestCase):
def setUp(self):
self._session_init()
self.session_init()
def test_missing_required_field(self):
"""Calling create_note without title should return VALIDATION_ERROR."""
data = self._call("create_note", {})
reply = self.call("create_note", {})
self.assertTrue(data["error"])
self.assertEqual(data["code"], "VALIDATION_ERROR")
self.assertEqual(reply.status, 422)
self.assertEqual(reply.body["code"], "VALIDATION_ERROR")
self.assertIn("title", reply.body["details"]["fields"])
def test_wrong_type(self):
"""Calling delete_note with string id should return VALIDATION_ERROR."""
data = self._call("delete_note", {"id": "not-an-int"})
reply = self.call("delete_note", {"id": "not-an-int"})
self.assertTrue(data["error"])
self.assertEqual(data["code"], "VALIDATION_ERROR")
self.assertEqual(reply.status, 422)
self.assertEqual(reply.body["code"], "VALIDATION_ERROR")
def test_missing_multiple_fields(self):
"""write_file with no args should list all missing fields."""
data = self._call("write_file", {})
reply = self.call("write_file", {})
self.assertTrue(data["error"])
self.assertEqual(data["code"], "VALIDATION_ERROR")
self.assertEqual(reply.status, 422)
self.assertEqual(reply.body["code"], "VALIDATION_ERROR")
self.assertEqual(
set(reply.body["details"]["fields"]), {"path", "content"}
)
class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
"""Error codes over real HTTP."""
class ErrorCodeTests(LiveRPCMixin, LiveServerTestCase):
def setUp(self):
self._session_init()
self.session_init()
def test_not_found_function(self):
data = self._call("this_does_not_exist")
reply = self.call("this_does_not_exist")
self.assertTrue(data["error"])
self.assertEqual(data["code"], "NOT_FOUND")
self.assertEqual(reply.status, 404)
self.assertEqual(reply.body["code"], "NOT_FOUND")
def test_forbidden_write_outside_home(self):
data = self._call("write_file", {"path": "/etc/nope.txt", "content": "x"})
reply = self.call("write_file", {"path": "/etc/nope.txt", "content": "x"})
self.assertTrue(data["error"])
self.assertEqual(data["code"], "FORBIDDEN")
self.assertEqual(reply.status, 403)
self.assertEqual(reply.body["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])
reply = self.get("/api/mizan/call/")
self.assertEqual(reply.status, 405)
self.assertEqual(reply.body["code"], "BAD_REQUEST")
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])
reply = self.post("/api/mizan/call/", "not valid json{{{")
self.assertEqual(reply.status, 400)
self.assertEqual(reply.body["code"], "BAD_REQUEST")
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])
reply = self.post("/api/mizan/call/", json.dumps({"not_fn": "hello"}))
self.assertEqual(reply.status, 400)
self.assertEqual(reply.body["code"], "BAD_REQUEST")
def test_empty_body(self):
reply = self.post("/api/mizan/call/", b"")
self.assertEqual(reply.status, 400)
self.assertEqual(reply.body["code"], "BAD_REQUEST")

View File

@@ -1,143 +1,90 @@
"""
REAL integration tests for notes CRUD over HTTP.
Every test makes actual HTTP requests to a live Django server.
"""
import json
"""Notes CRUD driven over HTTP against a live Django server."""
from django.test import LiveServerTestCase
from urllib.request import urlopen, Request
from tests.live_http import LiveRPCMixin
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."""
class NotesCRUDTests(LiveRPCMixin, LiveServerTestCase):
def setUp(self):
self._session_init()
self.session_init()
def test_list_notes_empty(self):
data = self._call("list_notes")
result = self.result("list_notes")
self.assertFalse(data["error"])
self.assertEqual(data["data"]["notes"], [])
self.assertEqual(data["data"]["count"], 0)
self.assertEqual(result["notes"], [])
self.assertEqual(result["count"], 0)
def test_create_note(self):
data = self._call("create_note", {"title": "First Note", "content": "Hello!"})
result = self.result("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"])
self.assertEqual(result["title"], "First Note")
self.assertEqual(result["content"], "Hello!")
self.assertFalse(result["pinned"])
self.assertIn("id", result)
self.assertIn("created_at", result)
def test_create_and_list(self):
self._call("create_note", {"title": "Note A"})
self._call("create_note", {"title": "Note B"})
self.result("create_note", {"title": "Note A"})
self.result("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"]]
result = self.result("list_notes")
self.assertEqual(result["count"], 2)
titles = [n["title"] for n in result["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"]
note_id = self.result("create_note", {"title": "Get Me", "content": "Specific"})["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")
result = self.result("get_note", {"id": note_id})
self.assertEqual(result["id"], note_id)
self.assertEqual(result["title"], "Get Me")
def test_update_note(self):
create = self._call("create_note", {"title": "Original"})
note_id = create["data"]["id"]
note_id = self.result("create_note", {"title": "Original"})["id"]
data = self._call("update_note", {"id": note_id, "title": "Updated"})
self.assertFalse(data["error"])
self.assertEqual(data["data"]["title"], "Updated")
result = self.result("update_note", {"id": note_id, "title": "Updated"})
self.assertEqual(result["title"], "Updated")
def test_update_note_pin(self):
create = self._call("create_note", {"title": "Pin Me"})
note_id = create["data"]["id"]
note_id = self.result("create_note", {"title": "Pin Me"})["id"]
data = self._call("update_note", {"id": note_id, "pinned": True})
self.assertFalse(data["error"])
self.assertTrue(data["data"]["pinned"])
result = self.result("update_note", {"id": note_id, "pinned": True})
self.assertTrue(result["pinned"])
def test_delete_note(self):
create = self._call("create_note", {"title": "Delete Me"})
note_id = create["data"]["id"]
note_id = self.result("create_note", {"title": "Delete Me"})["id"]
data = self._call("delete_note", {"id": note_id})
self.assertFalse(data["error"])
self.assertTrue(data["data"]["deleted"])
self.assertTrue(self.result("delete_note", {"id": note_id})["deleted"])
# Verify it's gone
from urllib.error import HTTPError
# get_note raises ValueError for a missing row, which the executor maps
# onto the generic internal-error envelope rather than a 404.
reply = self.call("get_note", {"id": note_id})
self.assertEqual(reply.status, 500)
self.assertEqual(reply.body["code"], "INTERNAL_ERROR")
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_delete_absent_note_reports_not_deleted(self):
result = self.result("delete_note", {"id": 424242})
self.assertFalse(result["deleted"])
def test_pinned_notes_sort_first(self):
self._call("create_note", {"title": "Unpinned"})
self._call("create_note", {"title": "Pinned", "pinned": True})
self.result("create_note", {"title": "Unpinned"})
self.result("create_note", {"title": "Pinned", "pinned": True})
data = self._call("list_notes")
self.assertFalse(data["error"])
self.assertEqual(data["data"]["notes"][0]["title"], "Pinned")
result = self.result("list_notes")
self.assertEqual(result["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"]
note_id = self.result("create_note", {"title": "Lifecycle", "content": "v1"})["id"]
# Update
self._call("update_note", {"id": note_id, "content": "v2"})
self.result("update_note", {"id": note_id, "content": "v2"})
self.result("update_note", {"id": note_id, "pinned": True})
# Pin
self._call("update_note", {"id": note_id, "pinned": True})
fetched = self.result("get_note", {"id": note_id})
self.assertEqual(fetched["title"], "Lifecycle")
self.assertEqual(fetched["content"], "v2")
self.assertTrue(fetched["pinned"])
# 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"])
self.assertTrue(self.result("delete_note", {"id": note_id})["deleted"])

View File

@@ -1,165 +1,99 @@
"""
REAL integration tests for desktop system RPC functions.
"""Desktop system and filesystem RPC functions over HTTP."""
These make actual HTTP requests to a running Django server.
No RequestFactory, no mocks, no shortcuts.
"""
import json
import os
import platform
import shutil
from pathlib import Path
from django.test import LiveServerTestCase
from urllib.request import urlopen, Request
from tests.live_http import LiveRPCMixin
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."""
class SystemInfoTests(LiveRPCMixin, LiveServerTestCase):
def setUp(self):
self._session_init()
self.session_init()
def test_system_info_returns_os_data(self):
data = self._call("system_info")
result = self.result("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)
self.assertEqual(result["os_name"], platform.system())
self.assertEqual(result["hostname"], platform.node())
self.assertGreater(result["cpu_count"], 0)
def test_system_info_returns_paths(self):
data = self._call("system_info")
result = self.result("system_info")
self.assertFalse(data["error"])
self.assertEqual(data["data"]["home_dir"], str(Path.home()))
self.assertEqual(data["data"]["cwd"], os.getcwd())
self.assertEqual(result["home_dir"], str(Path.home()))
self.assertEqual(result["cwd"], os.getcwd())
def test_disk_usage(self):
data = self._call("disk_usage", {"path": "/"})
result = self.result("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)
self.assertGreater(result["total_gb"], 0)
self.assertGreater(result["free_gb"], 0)
self.assertGreaterEqual(result["percent_used"], 0)
self.assertLessEqual(result["percent_used"], 100)
def test_app_info(self):
data = self._call("app_info")
result = self.result("app_info")
self.assertFalse(data["error"])
self.assertEqual(data["data"]["app_name"], "mizan Desktop")
self.assertGreater(data["data"]["uptime_seconds"], 0)
self.assertEqual(result["app_name"], "mizan Desktop")
self.assertGreater(result["uptime_seconds"], 0)
class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
"""File system RPC over real HTTP."""
class FileSystemTests(LiveRPCMixin, LiveServerTestCase):
def setUp(self):
self._session_init()
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": "~"})
result = self.result("list_files", {"directory": "~"})
self.assertFalse(data["error"])
self.assertEqual(data["data"]["directory"], str(Path.home()))
self.assertIsInstance(data["data"]["entries"], list)
self.assertEqual(result["directory"], str(Path.home()))
self.assertIsInstance(result["entries"], list)
def test_list_files_root_has_no_parent(self):
data = self._call("list_files", {"directory": "/"})
result = self.result("list_files", {"directory": "/"})
self.assertFalse(data["error"])
self.assertIsNone(data["data"]["parent"])
self.assertIsNone(result["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!"
test_content = "Hello from an 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)
written = self.result("write_file", {"path": test_path, "content": test_content})
self.assertEqual(written["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)
read_back = self.result("read_file", {"path": test_path})
self.assertEqual(read_back["content"], test_content)
def test_write_outside_home_rejected(self):
"""Server should reject writes outside home directory."""
from urllib.error import HTTPError
reply = self.call("write_file", {"path": "/tmp/escape.txt", "content": "nope"})
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)
self.assertEqual(reply.status, 403)
self.assertEqual(reply.body["code"], "FORBIDDEN")
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"])
result = self.result("delete_file", {"path": test_path})
self.assertTrue(result["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)
result = self.result("list_files", {"directory": str(self.test_dir)})
self.assertGreater(len(result["entries"]), 0)
entry = data["data"]["entries"][0]
entry = result["entries"][0]
self.assertIn("name", entry)
self.assertIn("path", entry)
self.assertIn("is_dir", entry)

View File

@@ -1,18 +1,9 @@
"""
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.channels import Channel
from mizan.setup import register, register_form, register_as
from mizan.channels import register as register_channel
from mizan.forms import mizanFormMixin, mizanFormMeta
@@ -85,7 +76,7 @@ class LoginForm(forms.Form):
def handle_login(request, form):
"""Login form submit handler."""
"""Authenticate the credentials and open a session."""
from django.contrib.auth import authenticate, login
user = authenticate(
@@ -109,7 +100,7 @@ class SignupForm(forms.Form):
def handle_signup(request, form):
"""Signup form submit handler."""
"""Create the user, surfacing any creation failure as a form error."""
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -118,11 +109,12 @@ def handle_signup(request, form):
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
return {"success": True, "data": {"user_id": user.pk}}
register_form(SignupForm, "signup", submit_handler=handle_signup)
@@ -139,36 +131,35 @@ register_form(AddEmailForm, "add_email")
# =============================================================================
class ChatChannel(ReactChannel):
class ChatChannel(Channel):
class Params(BaseModel):
room: str
class ReactMessage(BaseModel):
class ClientMessage(BaseModel):
text: str
class DjangoMessage(BaseModel):
class ServerMessage(BaseModel):
text: str
def authorize(self, params=None):
return True
return bool(params and params.room)
def group(self, params=None):
room = params.room if params else "default"
return f"chat_{room}"
return f"chat_{params.room}"
def receive(self, params, msg):
return self.DjangoMessage(text=msg.text)
return self.ServerMessage(text=msg.text)
register_channel(ChatChannel, "chat")
class NotificationsChannel(ReactChannel):
class DjangoMessage(BaseModel):
class NotificationsChannel(Channel):
class ServerMessage(BaseModel):
text: str
def authorize(self, params=None):
return True
return self.user.is_authenticated
def group(self, params=None):
return "notifications_global"
@@ -177,12 +168,12 @@ class NotificationsChannel(ReactChannel):
register_channel(NotificationsChannel, "notifications")
class PresenceChannel(ReactChannel):
class DjangoMessage(BaseModel):
class PresenceChannel(Channel):
class ServerMessage(BaseModel):
value: int
def authorize(self, params=None):
return True
return self.user.is_authenticated
def group(self, params=None):
return "presence_global"
@@ -290,24 +281,34 @@ class Multiply(ServerFunction):
# =============================================================================
# Error-producing Functions
# Functions carrying error branches
# =============================================================================
@client
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
raise NotImplementedError("This feature is not yet implemented")
register(not_implemented_fn, "not_implemented_fn")
_ECHO_TRANSFORMS = {"upper": str.upper, "lower": str.lower}
@client
def buggy_fn(request: HttpRequest) -> EchoOutput:
raise RuntimeError("Unexpected internal failure")
def echo_transform(request: HttpRequest, text: str, mode: str) -> EchoOutput:
transform = _ECHO_TRANSFORMS.get(mode)
if transform is None:
raise NotImplementedError(f"echo mode {mode!r} has no transform")
return EchoOutput(message=transform(text))
register(buggy_fn, "buggy_fn")
register(echo_transform, "echo_transform")
class DivideOutput(BaseModel):
quotient: float
@client
def divide(request: HttpRequest, numerator: int, denominator: int) -> DivideOutput:
return DivideOutput(quotient=numerator / denominator)
register(divide, "divide")
@client
@@ -384,16 +385,16 @@ class ItemForm(mizanFormMixin, forms.Form):
# =============================================================================
# Auth-gated Channel
# Staff-gated Channel
# =============================================================================
class PrivateChannel(ReactChannel):
class DjangoMessage(BaseModel):
class PrivateChannel(Channel):
class ServerMessage(BaseModel):
text: str
def authorize(self, params=None):
return getattr(self.user, "is_authenticated", False)
return self.user.is_staff
def group(self, params=None):
return "private_global"

View File

@@ -1,14 +1,11 @@
"""
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)
"""
"""Django settings for the integration-test backend the E2E harness talks to."""
import os
from pathlib import Path
# The directory holding manage.py and the app packages; mizan's client
# discovery walks the installed apps beneath it.
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "integration-test-secret-key-not-for-production"
@@ -41,7 +38,7 @@ TEMPLATES = [
"OPTIONS": {
"worker": os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..",
"packages", "mizan-ssr", "src", "worker.tsx",
"workers", "mizan-ssr", "src", "worker.tsx",
),
},
},

View File

@@ -1,22 +0,0 @@
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, 'backends/mizan-django/.venv/bin/python')],
env: {
PYTHONPATH: `${path.join(root, 'backends/mizan-django/src')}:${path.join(root, 'examples/django-react-site/backend')}`,
DJANGO_SETTINGS_MODULE: 'testapp.settings',
},
},
},
output: 'src/api',
}

View File

@@ -0,0 +1,8 @@
project_id = "e2e-harness"
output = "src/api"
targets = ["react"]
[source.django]
manage_path = "../backend/manage.py"
# Resolved from the manage.py directory, which is the export subprocess's cwd.
command = ["uv", "run", "--project", "../../../backends/mizan-django", "python"]

View File

@@ -3,7 +3,10 @@
"private": true,
"type": "module",
"scripts": {
"generate": "mizan-generate --config mizan.toml",
"prebuild": "npm run generate",
"build": "vite build",
"predev": "npm run generate",
"dev": "vite --port 5174"
},
"dependencies": {

View File

@@ -0,0 +1 @@
* linguist-generated=true

View File

@@ -1,40 +1,22 @@
'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'
import type { ChatParams, ChatClientMessage, ChatServerMessage, NotificationsServerMessage, PresenceServerMessage, PrivateServerMessage } from './channels'
// ============================================================================
// Channel Hooks
// ============================================================================
/**
* Hook for the chat channel.
*/
export function useChatChannel(params: ChatParams): ChannelSubscription<ChatParams, ChatDjangoMessage, ChatReactMessage> {
return useChannel('chat', params)
export function useChatChannel(params: ChatParams): ChannelSubscription<ChatParams, ChatServerMessage, ChatClientMessage> {
return useChannel('chat', params)
}
/**
* Hook for the notifications channel.
*/
export function useNotificationsChannel(): ChannelSubscription<Record<string, never>, NotificationsDjangoMessage, never> {
return useChannel('notifications', {})
export function useNotificationsChannel(): ChannelSubscription<Record<string, never>, NotificationsServerMessage, never> {
return useChannel('notifications', {})
}
/**
* Hook for the presence channel.
*/
export function usePresenceChannel(): ChannelSubscription<Record<string, never>, PresenceDjangoMessage, never> {
return useChannel('presence', {})
export function usePresenceChannel(): ChannelSubscription<Record<string, never>, PresenceServerMessage, never> {
return useChannel('presence', {})
}
/**
* Hook for the private channel.
*/
export function usePrivateChannel(): ChannelSubscription<Record<string, never>, PrivateDjangoMessage, never> {
return useChannel('private', {})
export function usePrivateChannel(): ChannelSubscription<Record<string, never>, PrivateServerMessage, never> {
return useChannel('private', {})
}

View File

@@ -1,337 +1,48 @@
// 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"];
};
};
};
};
export interface ChatClientMessage {
text: string
}
export interface ChatParams {
room: string
}
// ============================================================================
// Convenience Type Exports
// ============================================================================
export interface ChatServerMessage {
text: string
}
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"]
export interface NotificationsServerMessage {
text: string
}
// ============================================================================
// Channel Registry
// ============================================================================
export interface PresenceServerMessage {
value: number
}
export interface PrivateServerMessage {
text: string
}
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',
},
'chat': {
name: 'chat',
pascalName: 'Chat',
paramsType: 'ChatParams',
clientMessageType: 'ChatClientMessage',
serverMessageType: 'ChatServerMessage',
},
'notifications': {
name: 'notifications',
pascalName: 'Notifications',
serverMessageType: 'NotificationsServerMessage',
},
'presence': {
name: 'presence',
pascalName: 'Presence',
serverMessageType: 'PresenceServerMessage',
},
'private': {
name: 'private',
pascalName: 'Private',
serverMessageType: 'PrivateServerMessage',
},
} as const

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { currentUserOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { greetOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { addInput, addOutput } from '../types'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { buggyFnOutput } from '../types'
export function callBuggyFn(): Promise<buggyFnOutput> {
return mizanCall('buggy_fn', {})
}

View File

@@ -0,0 +1,7 @@
import { mizanCall } from '@mizan/base'
import type { divideInput, divideOutput } from '../types'
export function callDivide(args: divideInput): Promise<divideOutput> {
return mizanCall('divide', args)
}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'

View File

@@ -0,0 +1,7 @@
import { mizanCall } from '@mizan/base'
import type { echoTransformInput, echoTransformOutput } from '../types'
export function callEchoTransform(args: echoTransformInput): Promise<echoTransformOutput> {
return mizanCall('echo_transform', args)
}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { httpOnlyEchoInput, httpOnlyEchoOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { jwtObtainOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { jwtRefreshInput, jwtRefreshOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { multiplyInput, multiplyOutput } from '../types'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { notImplementedFnOutput } from '../types'
export function callNotImplementedFn(): Promise<notImplementedFnOutput> {
return mizanCall('not_implemented_fn', {})
}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { permissionCheckFnInput, permissionCheckFnOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { staffOnlyOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { superuserOnlyOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { verifiedOnlyOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { wsWhoamiOutput } from '../types'

View File

@@ -1,24 +1,21 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchGlobalContext, type GlobalContextData, type GlobalContextParams } from './contexts/global'
export { fetchLocalContext, type LocalContextData, type LocalContextParams } from './contexts/local'
export { callEcho } from './functions/echo'
export { callAdd } from './functions/add'
export { callWhoami } from './functions/whoami'
export { callDivide } from './functions/divide'
export { callEcho } from './functions/echo'
export { callEchoTransform } from './functions/echoTransform'
export { callHttpOnlyEcho } from './functions/httpOnlyEcho'
export { callJwtObtain } from './functions/jwtObtain'
export { callJwtRefresh } from './functions/jwtRefresh'
export { callMultiply } from './functions/multiply'
export { callPermissionCheckFn } from './functions/permissionCheckFn'
export { callStaffOnly } from './functions/staffOnly'
export { callSuperuserOnly } from './functions/superuserOnly'
export { callVerifiedOnly } from './functions/verifiedOnly'
export { callMultiply } from './functions/multiply'
export { callNotImplementedFn } from './functions/notImplementedFn'
export { callBuggyFn } from './functions/buggyFn'
export { callPermissionCheckFn } from './functions/permissionCheckFn'
export { callWhoami } from './functions/whoami'
export { callWsWhoami } from './functions/wsWhoami'
export { callJwtObtain } from './functions/jwtObtain'
export { callJwtRefresh } from './functions/jwtRefresh'
// Stage 2 framework adapter
export * from './react'

View File

@@ -1,7 +1,5 @@
'use client'
// AUTO-GENERATED by mizan — do not edit
import {
createContext,
useCallback,
@@ -14,17 +12,15 @@ import {
} from 'react'
import {
configure,
initSession,
mizanCall,
mizanFetch,
MizanError,
registerContext,
type ContextState,
} from '@mizan/base'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchLocalContext, type LocalContextData, type LocalContextParams, callEcho, callAdd, callWhoami, callHttpOnlyEcho, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callMultiply, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callWsWhoami, callJwtObtain, callJwtRefresh, type currentUserOutput, type greetOutput } from './index'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchLocalContext, type LocalContextData, type LocalContextParams, callAdd, callDivide, callEcho, callEchoTransform, callHttpOnlyEcho, callJwtObtain, callJwtRefresh, callMultiply, callPermissionCheckFn, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callWhoami, callWsWhoami, type currentUserOutput, type greetOutput, } from './index'
// Internal — runs inside a Provider, registers with the kernel exactly once.
// Runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
name: string,
params: Record<string, any>,
@@ -45,7 +41,8 @@ function useContextSubscription<T>(
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
}
// Internal — wraps an imperative call() with isPending / error state.
// Wraps an imperative call() with isPending / error state.
interface MutationHook<TArgs, TResult> {
mutate: (args: TArgs) => Promise<TResult>
isPending: boolean
@@ -74,8 +71,6 @@ function useMutation<TArgs, TResult>(
return { mutate, isPending, error }
}
// ── Global Context ──
const GlobalCtx = createContext<ContextState<GlobalContextData> | null>(null)
export function GlobalContextProvider({ children }: { children: ReactNode }) {
@@ -94,8 +89,6 @@ export function useCurrentUser(): currentUserOutput | null {
return useGlobalContext().data?.current_user ?? null
}
// ── Local Context ──
const LocalCtx = createContext<ContextState<LocalContextData> | null>(null)
export function LocalContext({ children, ...params }: LocalContextParams & { children: ReactNode }) {
@@ -113,22 +106,42 @@ export function useGreet(): greetOutput | null {
return useLocalContext().data?.greet ?? null
}
export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
export function useAdd() {
return useMutation<Parameters<typeof callAdd>[0], Awaited<ReturnType<typeof callAdd>>>(callAdd)
}
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
export function useDivide() {
return useMutation<Parameters<typeof callDivide>[0], Awaited<ReturnType<typeof callDivide>>>(callDivide)
}
export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
export function useEchoTransform() {
return useMutation<Parameters<typeof callEchoTransform>[0], Awaited<ReturnType<typeof callEchoTransform>>>(callEchoTransform)
}
export function useHttpOnlyEcho() {
return useMutation<Parameters<typeof callHttpOnlyEcho>[0], Awaited<ReturnType<typeof callHttpOnlyEcho>>>(callHttpOnlyEcho)
}
export function useJwtObtain() {
return useMutation<void, Awaited<ReturnType<typeof callJwtObtain>>>(() => callJwtObtain() as any)
}
export function useJwtRefresh() {
return useMutation<Parameters<typeof callJwtRefresh>[0], Awaited<ReturnType<typeof callJwtRefresh>>>(callJwtRefresh)
}
export function useMultiply() {
return useMutation<Parameters<typeof callMultiply>[0], Awaited<ReturnType<typeof callMultiply>>>(callMultiply)
}
export function usePermissionCheckFn() {
return useMutation<Parameters<typeof callPermissionCheckFn>[0], Awaited<ReturnType<typeof callPermissionCheckFn>>>(callPermissionCheckFn)
}
export function useStaffOnly() {
return useMutation<void, Awaited<ReturnType<typeof callStaffOnly>>>(() => callStaffOnly() as any)
}
@@ -141,36 +154,14 @@ export function useVerifiedOnly() {
return useMutation<void, Awaited<ReturnType<typeof callVerifiedOnly>>>(() => callVerifiedOnly() as any)
}
export function useMultiply() {
return useMutation<Parameters<typeof callMultiply>[0], Awaited<ReturnType<typeof callMultiply>>>(callMultiply)
}
export function useNotImplementedFn() {
return useMutation<void, Awaited<ReturnType<typeof callNotImplementedFn>>>(() => callNotImplementedFn() as any)
}
export function useBuggyFn() {
return useMutation<void, Awaited<ReturnType<typeof callBuggyFn>>>(() => callBuggyFn() as any)
}
export function usePermissionCheckFn() {
return useMutation<Parameters<typeof callPermissionCheckFn>[0], Awaited<ReturnType<typeof callPermissionCheckFn>>>(callPermissionCheckFn)
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
}
export function useWsWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWsWhoami>>>(() => callWsWhoami() as any)
}
export function useJwtObtain() {
return useMutation<void, Awaited<ReturnType<typeof callJwtObtain>>>(() => callJwtObtain() as any)
}
export function useJwtRefresh() {
return useMutation<Parameters<typeof callJwtRefresh>[0], Awaited<ReturnType<typeof callJwtRefresh>>>(callJwtRefresh)
}
// ── MizanContext root provider ──
export interface MizanContextProps {
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
baseUrl?: string
@@ -180,8 +171,8 @@ export interface MizanContextProps {
}
/**
* Root provider — calls configure() once and mounts the global context (if defined).
* Must wrap any component using Mizan-generated hooks.
* Calls configure() once and mounts the global context when one is defined.
* Every component reading a generated hook resolves through this provider.
*/
export function MizanContext({ baseUrl, session, children }: MizanContextProps) {
const configured = useRef(false)
@@ -195,12 +186,7 @@ export function MizanContext({ baseUrl, session, children }: MizanContextProps)
return <GlobalContextProvider>{children}</GlobalContextProvider>
}
// ── Imperative escape hatch ──
/**
* Returns the imperative kernel API. For test harnesses or rare cases where
* a typed generated hook does not fit. Most app code should use the typed hooks.
*/
/** The untyped kernel entry points, bound to the configured client. */
export function useMizan() {
return { call: mizanCall, fetch: mizanFetch }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,13 @@
/**
* 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.
* The URL hash selects which fixture component mounts; each one drives a
* generated mizan hook and writes the outcome into `data-testid` nodes that
* the Playwright spec reads.
*/
import { useState, useEffect, useRef } from 'react'
import { ChannelProvider } from 'mizan/channels'
// Generated typed hooks — the actual mizan API
import {
MizanContext,
useEcho,
useAdd,
useMultiply,
@@ -19,8 +15,8 @@ import {
useStaffOnly,
useSuperuserOnly,
useVerifiedOnly,
useNotImplementedFn,
useBuggyFn,
useEchoTransform,
useDivide,
usePermissionCheckFn,
useCurrentUser,
MizanError,
@@ -49,12 +45,11 @@ export function Fixtures() {
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 'not-implemented': return <UnsupportedEchoMode />
case 'internal-error': return <DivideByZero />
case 'permission-error': return <PermissionDenied />
case 'permission-success': return <PermissionGranted />
case 'context-current-user': return <ContextCurrentUser />
// Form fixtures removed — forms codegen deferred per Blazr scope
case 'channel-chat': return <ChannelChatFixture />
default: return <div data-testid="ready">Harness ready. Set #hash.</div>
}
@@ -117,15 +112,15 @@ function Multiply() {
}
function NotFound() {
// Deliberately call a non-existent function via the raw primitive
// Names a function the backend never registered, so no generated hook
// exists for it and the raw kernel primitive is the only way to send it.
const { call } = useMizan()
const [error, setError] = useState<unknown>()
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
useEffect(() => { call('does_not_exist', {}).catch(setError) }, [call])
return <Result error={error} />
}
function ValidationError() {
// Send wrong types to add (strings instead of numbers)
const { mutate } = useAdd()
const [error, setError] = useState<unknown>()
useEffect(() => { (mutate as any)({ a: 'not_a_number', b: 'also_not' }).catch(setError) }, [mutate])
@@ -152,22 +147,22 @@ function VerifiedOnly() {
return <Result data={data} error={error} />
}
function NotImplemented() {
const { data, error } = useRun(useNotImplementedFn)
function UnsupportedEchoMode() {
const { data, error } = useRun(useEchoTransform, { text: 'e2e-test', mode: 'rot13' })
return <Result data={data} error={error} />
}
function InternalError() {
const { data, error } = useRun(useBuggyFn)
function DivideByZero() {
const { data, error } = useRun(useDivide, { numerator: 1, denominator: 0 })
return <Result data={data} error={error} />
}
function PermissionError_() {
function PermissionDenied() {
const { data, error } = useRun(usePermissionCheckFn, { secret: 'wrong' })
return <Result data={data} error={error} />
}
function PermissionSuccess() {
function PermissionGranted() {
const { data, error } = useRun(usePermissionCheckFn, { secret: 'open-sesame' })
return <Result data={data} error={error} />
}
@@ -175,20 +170,19 @@ function PermissionSuccess() {
// ─── 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>
}
const user = useCurrentUser()
if (user === null) return <div data-testid="pending">loading context</div>
return <pre data-testid="result">{JSON.stringify(user)}</pre>
}
// ─── Channel fixtures ───────────────────────────────────────────────────────
function ChannelChatFixture() {
// MizanContext already includes ChannelProvider
return <ChannelChat />
return (
<ChannelProvider>
<ChannelChat />
</ChannelProvider>
)
}
function ChannelChat() {
@@ -197,9 +191,8 @@ function ChannelChat() {
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)
// The hook reports 'connected' only once the server confirms the
// subscription, and it passes through 'connecting' on the way there.
const wasConnecting = prevStatus.current === 'connecting'
prevStatus.current = chat.status

View File

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

View File

@@ -5,7 +5,15 @@
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"skipLibCheck": true
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"mizan": ["../../../frontends/mizan-react/src/index.ts"],
"mizan/channels": ["../../../frontends/mizan-react/src/channels/index.ts"],
"mizan/client": ["../../../frontends/mizan-react/src/client/index.ts"],
"mizan/client/react": ["../../../frontends/mizan-react/src/client/react.ts"],
"mizan/jwt": ["../../../frontends/mizan-react/src/jwt/index.ts"]
}
},
"include": ["src"]
}

View File

@@ -1,9 +1,6 @@
/**
* 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().
* Drives a real Chromium against the harness bundle (generated hooks) talking
* to the running Django backend. Each fixture is selected by URL hash.
*/
import { test, expect } from '@playwright/test'
@@ -104,14 +101,14 @@ test.describe('error codes from generated hooks', () => {
expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code)
})
test('useNotImplementedFn → NOT_IMPLEMENTED', async ({ page }) => {
test('useEchoTransform with an unregistered mode → NOT_IMPLEMENTED', async ({ page }) => {
await fixture(page, 'not-implemented')
const error = await getError(page)
expect(error!.type).toBe('MizanError')
expect(error!.code).toBe('NOT_IMPLEMENTED')
})
test('useBuggyFn → INTERNAL_ERROR', async ({ page }) => {
test('useDivide by zero → INTERNAL_ERROR', async ({ page }) => {
await fixture(page, 'internal-error')
const error = await getError(page)
expect(error!.type).toBe('MizanError')
@@ -131,7 +128,6 @@ test.describe('error codes from generated 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)
@@ -139,12 +135,10 @@ test.describe('generated context hooks', () => {
})
})
// ─── Form hooks ─── (removed; forms codegen deferred per Blazr scope) ──────
// ─── Channel hooks ──────────────────────────────────────────────────────────
test.describe('generated channel hooks', () => {
test.skip('useChatChannel receives echoed message', async ({ page }) => { // channels deferred per Blazr scope
test('useChatChannel receives echoed message', async ({ page }) => {
await page.goto(`${BASE}#channel-chat`)
await page.waitForFunction(
() => {

View File

@@ -1,17 +1,7 @@
"""
Example FastAPI app for the e2e harness — mirrors the surface that
examples/django-react-site/backend/testapp/clients.py exercises, minus
Django-only features (forms, channels, ws-whoami, session-bound JWT).
"""FastAPI app exposing the mizan server functions the e2e harness calls.
The fixture functions are designed to drive specific Playwright tests:
- success-path RPC (echo, add, multiply)
- auth requirements (whoami, staff_only, superuser_only, verified_only)
- error codes (not_implemented_fn, buggy_fn, permission_check_fn)
- a global context (current_user) for the bundled-fetch path
Anonymous access is the default — request.state.user is left unset so
the auth-required functions return UNAUTHORIZED, matching the harness
expectations for an anonymous browser session.
`request.state.user` is never set here, so every auth-gated function below
resolves against an anonymous caller.
"""
from __future__ import annotations
@@ -46,6 +36,10 @@ class MultiplyOutput(BaseModel):
product: int
class DivideOutput(BaseModel):
quotient: float
class UserOutput(BaseModel):
email: str
authenticated: bool
@@ -56,7 +50,44 @@ class MessageOutput(BaseModel):
message: str
# ─── Fixture functions ──────────────────────────────────────────────────────
# ─── Morph state ────────────────────────────────────────────────────────────
class MorphGroupMeta(BaseModel):
"""Group summary. Its field set stays disjoint from MorphLayer's so the
server's return-type slot resolver can tell the two morphs slots apart."""
id: int
label: str
count: int
class MorphLayer(BaseModel):
id: int
group_id: int
label: str
value: float
_morph_groups: list[MorphGroupMeta] = [
MorphGroupMeta(id=1, label="face", count=2),
]
_morph_layers: list[MorphLayer] = [
MorphLayer(id=1, group_id=1, label="brow", value=0.0),
MorphLayer(id=2, group_id=1, label="jaw", value=0.0),
]
def _morph_layer(layer_id: int) -> MorphLayer:
for layer in _morph_layers:
if layer.id == layer_id:
return layer
raise ValueError(f"unknown morph layer id={layer_id}")
# ─── Server functions ───────────────────────────────────────────────────────
@client
@@ -79,7 +110,7 @@ def multiply(request, x: int, y: int) -> MultiplyOutput:
@client(auth=True)
def whoami(request) -> UserOutput:
"""Returns the authenticated user's identity. Anonymous → UNAUTHORIZED."""
"""Returns the authenticated user's identity."""
user = request.state.user
return UserOutput(
email=getattr(user, "email", ""),
@@ -107,25 +138,31 @@ def _is_verified(request) -> bool:
@client(auth=_is_verified)
def verified_only(request) -> MessageOutput:
"""Verified-users-only endpoint. Anonymous → FORBIDDEN."""
"""Verified-users-only endpoint."""
return MessageOutput(message="verified access ok")
@client
def not_implemented_fn(request) -> MessageOutput:
"""Always raises NotImplementedError → NOT_IMPLEMENTED."""
raise NotImplementedError("This function is intentionally not implemented")
_ECHO_TRANSFORMS = {"upper": str.upper, "lower": str.lower}
@client
def buggy_fn(request) -> MessageOutput:
"""Always raises a generic exception → INTERNAL_ERROR."""
raise RuntimeError("Intentional bug for e2e testing")
def echo_transform(request, text: str, mode: str) -> EchoOutput:
"""Echoes the text through the named transform."""
transform = _ECHO_TRANSFORMS.get(mode)
if transform is None:
raise NotImplementedError(f"echo mode {mode!r} has no transform")
return EchoOutput(message=transform(text))
@client
def divide(request, numerator: int, denominator: int) -> DivideOutput:
"""Returns numerator / denominator."""
return DivideOutput(quotient=numerator / denominator)
@client
def permission_check_fn(request, secret: str) -> MessageOutput:
"""Wrong secret → FORBIDDEN; correct secret → success."""
"""Grants access only for the correct secret."""
if secret != "open-sesame":
raise Forbidden("Invalid secret")
return MessageOutput(message="access granted")
@@ -133,7 +170,7 @@ def permission_check_fn(request, secret: str) -> MessageOutput:
@client(context="global")
def current_user(request) -> UserOutput:
"""The global context auto-mounted at the React root."""
"""The global context, auto-mounted at the React root."""
user = getattr(getattr(request, "state", None), "user", None)
return UserOutput(
email=getattr(user, "email", "") if user else "",
@@ -142,56 +179,24 @@ def current_user(request) -> UserOutput:
)
# ─── Merge protocol fixtures ────────────────────────────────────────────────
class MorphGroupMeta(BaseModel):
"""Group summary — narrower shape than MorphLayer. Listed alongside
morph_layers so the server's slot resolver has to discriminate by
return-type rather than by bundle order."""
id: int
label: str
count: int
class MorphLayer(BaseModel):
id: int
group_id: int
label: str
value: float
_morph_groups: list[MorphGroupMeta] = [
MorphGroupMeta(id=1, label="face", count=2),
]
_morph_layers: list[MorphLayer] = [
MorphLayer(id=1, group_id=1, label="brow", value=0.0),
MorphLayer(id=2, group_id=1, label="jaw", value=0.0),
]
@client(context="morphs")
def morph_groups(request) -> list[MorphGroupMeta]:
"""Summary-shape slot — server must route MorphLayer mutations away from here."""
"""The group summaries in the morphs context bundle."""
return list(_morph_groups)
@client(context="morphs")
def morph_layers(request) -> list[MorphLayer]:
"""Detailed-shape slot — server routes MorphLayer mutations here."""
"""The layer rows in the morphs context bundle."""
return list(_morph_layers)
@client(merge="morphs")
def set_morph_value(request, id: int, value: float) -> MorphLayer:
"""Mutation that returns the changed row; kernel splices into morph_layers."""
for layer in _morph_layers:
if layer.id == id:
layer.value = value
return layer
raise ValueError(f"unknown morph layer id={id}")
"""Sets one layer's value and returns the changed row for the kernel to splice."""
layer = _morph_layer(id)
layer.value = value
return layer
# ─── Registration ───────────────────────────────────────────────────────────
@@ -204,8 +209,8 @@ register(whoami, "whoami")
register(staff_only, "staff_only")
register(superuser_only, "superuser_only")
register(verified_only, "verified_only")
register(not_implemented_fn, "not_implemented_fn")
register(buggy_fn, "buggy_fn")
register(echo_transform, "echo_transform")
register(divide, "divide")
register(permission_check_fn, "permission_check_fn")
register(current_user, "current_user")
register(morph_groups, "morph_groups")

View File

@@ -1,19 +0,0 @@
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '../../..')
export default {
projectId: 'mizan-fastapi-e2e',
source: {
fastapi: {
module: 'main',
cwd: path.join(root, 'examples/fastapi-react-site/backend'),
command: ['uv', 'run', 'python'],
},
},
output: 'src/api',
}

View File

@@ -0,0 +1,9 @@
project_id = "mizan-fastapi-e2e"
output = "src/api"
targets = ["react"]
[source.fastapi]
module = "main"
# Resolved from this file's directory; the backend project is the subprocess cwd.
cwd = "../backend"
command = ["uv", "run", "python"]

View File

@@ -3,7 +3,10 @@
"private": true,
"type": "module",
"scripts": {
"generate": "mizan-generate --config mizan.toml",
"prebuild": "npm run generate",
"build": "vite build",
"predev": "npm run generate",
"dev": "vite --port 5175"
},
"dependencies": {

View File

@@ -0,0 +1 @@
* linguist-generated=true

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { currentUserOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { morphGroupsOutput, morphLayersOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { addInput, addOutput } from '../types'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { buggyFnOutput } from '../types'
export function callBuggyFn(): Promise<buggyFnOutput> {
return mizanCall('buggy_fn', {})
}

View File

@@ -0,0 +1,7 @@
import { mizanCall } from '@mizan/base'
import type { divideInput, divideOutput } from '../types'
export function callDivide(args: divideInput): Promise<divideOutput> {
return mizanCall('divide', args)
}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'

View File

@@ -0,0 +1,7 @@
import { mizanCall } from '@mizan/base'
import type { echoTransformInput, echoTransformOutput } from '../types'
export function callEchoTransform(args: echoTransformInput): Promise<echoTransformOutput> {
return mizanCall('echo_transform', args)
}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { multiplyInput, multiplyOutput } from '../types'

View File

@@ -1,9 +0,0 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { notImplementedFnOutput } from '../types'
export function callNotImplementedFn(): Promise<notImplementedFnOutput> {
return mizanCall('not_implemented_fn', {})
}

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { permissionCheckFnInput, permissionCheckFnOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { setMorphValueInput, setMorphValueOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { staffOnlyOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { superuserOnlyOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { verifiedOnlyOutput } from '../types'

View File

@@ -1,5 +1,3 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'

View File

@@ -1,21 +1,18 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchGlobalContext, type GlobalContextData, type GlobalContextParams } from './contexts/global'
export { fetchMorphsContext, type MorphsContextData, type MorphsContextParams } from './contexts/morphs'
export { callEcho } from './functions/echo'
export { callAdd } from './functions/add'
export { callDivide } from './functions/divide'
export { callEcho } from './functions/echo'
export { callEchoTransform } from './functions/echoTransform'
export { callMultiply } from './functions/multiply'
export { callWhoami } from './functions/whoami'
export { callPermissionCheckFn } from './functions/permissionCheckFn'
export { callSetMorphValue } from './functions/setMorphValue'
export { callStaffOnly } from './functions/staffOnly'
export { callSuperuserOnly } from './functions/superuserOnly'
export { callVerifiedOnly } from './functions/verifiedOnly'
export { callNotImplementedFn } from './functions/notImplementedFn'
export { callBuggyFn } from './functions/buggyFn'
export { callPermissionCheckFn } from './functions/permissionCheckFn'
export { callSetMorphValue } from './functions/setMorphValue'
export { callWhoami } from './functions/whoami'
// Stage 2 framework adapter
export * from './react'

View File

@@ -1,7 +1,5 @@
'use client'
// AUTO-GENERATED by mizan — do not edit
import {
createContext,
useCallback,
@@ -14,17 +12,15 @@ import {
} from 'react'
import {
configure,
initSession,
mizanCall,
mizanFetch,
MizanError,
registerContext,
type ContextState,
} from '@mizan/base'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchMorphsContext, type MorphsContextData, type MorphsContextParams, callEcho, callAdd, callMultiply, callWhoami, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callNotImplementedFn, callBuggyFn, callPermissionCheckFn, callSetMorphValue, type currentUserOutput, type morphGroupsOutput, type morphLayersOutput } from './index'
import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, fetchMorphsContext, type MorphsContextData, type MorphsContextParams, callAdd, callDivide, callEcho, callEchoTransform, callMultiply, callPermissionCheckFn, callSetMorphValue, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callWhoami, type currentUserOutput, type morphGroupsOutput, type morphLayersOutput, } from './index'
// Internal — runs inside a Provider, registers with the kernel exactly once.
// Runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
name: string,
params: Record<string, any>,
@@ -45,7 +41,8 @@ function useContextSubscription<T>(
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
}
// Internal — wraps an imperative call() with isPending / error state.
// Wraps an imperative call() with isPending / error state.
interface MutationHook<TArgs, TResult> {
mutate: (args: TArgs) => Promise<TResult>
isPending: boolean
@@ -74,8 +71,6 @@ function useMutation<TArgs, TResult>(
return { mutate, isPending, error }
}
// ── Global Context ──
const GlobalCtx = createContext<ContextState<GlobalContextData> | null>(null)
export function GlobalContextProvider({ children }: { children: ReactNode }) {
@@ -94,8 +89,6 @@ export function useCurrentUser(): currentUserOutput | null {
return useGlobalContext().data?.current_user ?? null
}
// ── Morphs Context ──
const MorphsCtx = createContext<ContextState<MorphsContextData> | null>(null)
export function MorphsContext({ children }: { children: ReactNode }) {
@@ -117,20 +110,32 @@ export function useMorphLayers(): morphLayersOutput | null {
return useMorphsContext().data?.morph_layers ?? null
}
export function useAdd() {
return useMutation<Parameters<typeof callAdd>[0], Awaited<ReturnType<typeof callAdd>>>(callAdd)
}
export function useDivide() {
return useMutation<Parameters<typeof callDivide>[0], Awaited<ReturnType<typeof callDivide>>>(callDivide)
}
export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
export function useAdd() {
return useMutation<Parameters<typeof callAdd>[0], Awaited<ReturnType<typeof callAdd>>>(callAdd)
export function useEchoTransform() {
return useMutation<Parameters<typeof callEchoTransform>[0], Awaited<ReturnType<typeof callEchoTransform>>>(callEchoTransform)
}
export function useMultiply() {
return useMutation<Parameters<typeof callMultiply>[0], Awaited<ReturnType<typeof callMultiply>>>(callMultiply)
}
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
export function usePermissionCheckFn() {
return useMutation<Parameters<typeof callPermissionCheckFn>[0], Awaited<ReturnType<typeof callPermissionCheckFn>>>(callPermissionCheckFn)
}
export function useSetMorphValue() {
return useMutation<Parameters<typeof callSetMorphValue>[0], Awaited<ReturnType<typeof callSetMorphValue>>>(callSetMorphValue)
}
export function useStaffOnly() {
@@ -145,24 +150,10 @@ export function useVerifiedOnly() {
return useMutation<void, Awaited<ReturnType<typeof callVerifiedOnly>>>(() => callVerifiedOnly() as any)
}
export function useNotImplementedFn() {
return useMutation<void, Awaited<ReturnType<typeof callNotImplementedFn>>>(() => callNotImplementedFn() as any)
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
}
export function useBuggyFn() {
return useMutation<void, Awaited<ReturnType<typeof callBuggyFn>>>(() => callBuggyFn() as any)
}
export function usePermissionCheckFn() {
return useMutation<Parameters<typeof callPermissionCheckFn>[0], Awaited<ReturnType<typeof callPermissionCheckFn>>>(callPermissionCheckFn)
}
export function useSetMorphValue() {
return useMutation<Parameters<typeof callSetMorphValue>[0], Awaited<ReturnType<typeof callSetMorphValue>>>(callSetMorphValue)
}
// ── MizanContext root provider ──
export interface MizanContextProps {
/** Base URL for protocol endpoints. Defaults to "/api/mizan". */
baseUrl?: string
@@ -172,8 +163,8 @@ export interface MizanContextProps {
}
/**
* Root provider — calls configure() once and mounts the global context (if defined).
* Must wrap any component using Mizan-generated hooks.
* Calls configure() once and mounts the global context when one is defined.
* Every component reading a generated hook resolves through this provider.
*/
export function MizanContext({ baseUrl, session, children }: MizanContextProps) {
const configured = useRef(false)
@@ -187,12 +178,7 @@ export function MizanContext({ baseUrl, session, children }: MizanContextProps)
return <GlobalContextProvider>{children}</GlobalContextProvider>
}
// ── Imperative escape hatch ──
/**
* Returns the imperative kernel API. For test harnesses or rare cases where
* a typed generated hook does not fit. Most app code should use the typed hooks.
*/
/** The untyped kernel entry points, bound to the configured client. */
export function useMizan() {
return { call: mizanCall, fetch: mizanFetch }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,784 +1,105 @@
// AUTO-GENERATED by mizan — do not edit
export interface paths {
"/mizan/echo": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Echoes the input text. */
post: operations["echo"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/add": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Returns a + b. */
post: operations["add"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/multiply": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Returns x * y. */
post: operations["multiply"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/whoami": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Returns the authenticated user's identity. Anonymous → UNAUTHORIZED. */
post: operations["whoami"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/staff_only": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Staff-only endpoint. */
post: operations["staffOnly"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/superuser_only": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Superuser-only endpoint. */
post: operations["superuserOnly"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/verified_only": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Verified-users-only endpoint. Anonymous → FORBIDDEN. */
post: operations["verifiedOnly"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/not_implemented_fn": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Always raises NotImplementedError → NOT_IMPLEMENTED. */
post: operations["notImplementedFn"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/buggy_fn": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Always raises a generic exception → INTERNAL_ERROR. */
post: operations["buggyFn"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/permission_check_fn": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Wrong secret → FORBIDDEN; correct secret → success. */
post: operations["permissionCheckFn"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/current_user": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** The global context — auto-mounted at the React root. */
post: operations["currentUser"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/morph_groups": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Summary-shape slot — server must route MorphLayer mutations away from here. */
post: operations["morphGroups"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/morph_layers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Detailed-shape slot — server routes MorphLayer mutations here. */
post: operations["morphLayers"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mizan/set_morph_value": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Mutation that returns the changed row; kernel splices into morph_layers. */
post: operations["setMorphValue"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/**
* MorphGroupMeta
* @description Group summary — narrower shape than MorphLayer. Listed alongside
* morph_layers so the server's slot resolver has to discriminate by
* return-type rather than by bundle order.
*/
MorphGroupMeta: {
/** Id */
id: number;
/** Label */
label: string;
/** Count */
count: number;
};
/** MorphLayer */
MorphLayer: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Label */
label: string;
/** Value */
value: number;
};
/** ValidationError */
ValidationError: {
/** Location */
loc: (string | number)[];
/** Message */
msg: string;
/** Error Type */
type: string;
/** Input */
input?: unknown;
/** Context */
ctx?: Record<string, never>;
};
/** addInput */
addInput: {
/** A */
a: number;
/** B */
b: number;
};
/** addOutput */
addOutput: {
/** Result */
result: number;
};
/** buggyFnOutput */
buggyFnOutput: {
/** Message */
message: string;
};
/** currentUserOutput */
currentUserOutput: {
/** Email */
email: string;
/** Authenticated */
authenticated: boolean;
/**
* Is Staff
* @default false
*/
is_staff: boolean;
};
/** echoInput */
echoInput: {
/** Text */
text: string;
};
/** echoOutput */
echoOutput: {
/** Message */
message: string;
};
/** morphGroupsOutput */
morphGroupsOutput: components["schemas"]["MorphGroupMeta"][];
/** morphLayersOutput */
morphLayersOutput: components["schemas"]["MorphLayer"][];
/** multiplyInput */
multiplyInput: {
/** X */
x: number;
/** Y */
y: number;
};
/** multiplyOutput */
multiplyOutput: {
/** Product */
product: number;
};
/** notImplementedFnOutput */
notImplementedFnOutput: {
/** Message */
message: string;
};
/** permissionCheckFnInput */
permissionCheckFnInput: {
/** Secret */
secret: string;
};
/** permissionCheckFnOutput */
permissionCheckFnOutput: {
/** Message */
message: string;
};
/** setMorphValueInput */
setMorphValueInput: {
/** Id */
id: number;
/** Value */
value: number;
};
/** setMorphValueOutput */
setMorphValueOutput: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Label */
label: string;
/** Value */
value: number;
};
/** staffOnlyOutput */
staffOnlyOutput: {
/** Message */
message: string;
};
/** superuserOnlyOutput */
superuserOnlyOutput: {
/** Message */
message: string;
};
/** verifiedOnlyOutput */
verifiedOnlyOutput: {
/** Message */
message: string;
};
/** whoamiOutput */
whoamiOutput: {
/** Email */
email: string;
/** Authenticated */
authenticated: boolean;
/**
* Is Staff
* @default false
*/
is_staff: boolean;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
echo: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["echoInput"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["echoOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
add: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["addInput"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["addOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
multiply: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["multiplyInput"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["multiplyOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
whoami: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["whoamiOutput"];
};
};
};
};
staffOnly: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["staffOnlyOutput"];
};
};
};
};
superuserOnly: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["superuserOnlyOutput"];
};
};
};
};
verifiedOnly: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["verifiedOnlyOutput"];
};
};
};
};
notImplementedFn: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["notImplementedFnOutput"];
};
};
};
};
buggyFn: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["buggyFnOutput"];
};
};
};
};
permissionCheckFn: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["permissionCheckFnInput"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["permissionCheckFnOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
currentUser: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["currentUserOutput"];
};
};
};
};
morphGroups: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["morphGroupsOutput"];
};
};
};
};
morphLayers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["morphLayersOutput"];
};
};
};
};
setMorphValue: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["setMorphValueInput"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["setMorphValueOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export interface MorphGroupMeta {
id: number
label: string
count: number
}
export interface MorphLayer {
id: number
group_id: number
label: string
value: number
}
export interface addInput {
a: number
b: number
}
export interface addOutput {
result: number
}
export interface currentUserOutput {
email: string
authenticated: boolean
is_staff: boolean
}
export interface divideInput {
numerator: number
denominator: number
}
export interface divideOutput {
quotient: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface echoTransformInput {
text: string
mode: string
}
export interface echoTransformOutput {
message: string
}
export type morphGroupsOutput = MorphGroupMeta[]
export type morphLayersOutput = MorphLayer[]
export interface multiplyInput {
x: number
y: number
}
export interface multiplyOutput {
product: number
}
export interface permissionCheckFnInput {
secret: string
}
export interface permissionCheckFnOutput {
message: string
}
export interface setMorphValueInput {
id: number
value: number
}
export interface setMorphValueOutput {
id: number
group_id: number
label: string
value: number
}
export interface staffOnlyOutput {
message: string
}
export interface superuserOnlyOutput {
message: string
}
export interface verifiedOnlyOutput {
message: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
is_staff: boolean
}
// Convenience type exports
export type HTTPValidationError = components["schemas"]["HTTPValidationError"]
export type MorphGroupMeta = components["schemas"]["MorphGroupMeta"]
export type MorphLayer = components["schemas"]["MorphLayer"]
export type ValidationError = components["schemas"]["ValidationError"]
export type addInput = components["schemas"]["addInput"]
export type addOutput = components["schemas"]["addOutput"]
export type buggyFnOutput = components["schemas"]["buggyFnOutput"]
export type currentUserOutput = components["schemas"]["currentUserOutput"]
export type echoInput = components["schemas"]["echoInput"]
export type echoOutput = components["schemas"]["echoOutput"]
export type morphGroupsOutput = components["schemas"]["morphGroupsOutput"]
export type morphLayersOutput = components["schemas"]["morphLayersOutput"]
export type multiplyInput = components["schemas"]["multiplyInput"]
export type multiplyOutput = components["schemas"]["multiplyOutput"]
export type notImplementedFnOutput = components["schemas"]["notImplementedFnOutput"]
export type permissionCheckFnInput = components["schemas"]["permissionCheckFnInput"]
export type permissionCheckFnOutput = components["schemas"]["permissionCheckFnOutput"]
export type setMorphValueInput = components["schemas"]["setMorphValueInput"]
export type setMorphValueOutput = components["schemas"]["setMorphValueOutput"]
export type staffOnlyOutput = components["schemas"]["staffOnlyOutput"]
export type superuserOnlyOutput = components["schemas"]["superuserOnlyOutput"]
export type verifiedOnlyOutput = components["schemas"]["verifiedOnlyOutput"]
export type whoamiOutput = components["schemas"]["whoamiOutput"]

View File

@@ -1,16 +1,11 @@
/**
* E2E Test Fixtures — FastAPI flavor.
*
* Mirrors examples/django-react-site/harness/src/fixtures.tsx minus the
* Django-only surfaces (channels, forms). Each fixture uses GENERATED
* mizan hooks (not raw call()). Playwright reads the DOM to verify
* behavior. URL hash selects the fixture: #echo, #add, etc.
* The URL hash selects which component renders; each one drives one generated
* hook and writes its outcome into data-testid nodes the Playwright spec reads.
*/
import { useState, useEffect } from 'react'
import {
MizanContext,
MorphsContext,
useEcho,
useAdd,
@@ -19,8 +14,8 @@ import {
useStaffOnly,
useSuperuserOnly,
useVerifiedOnly,
useNotImplementedFn,
useBuggyFn,
useEchoTransform,
useDivide,
usePermissionCheckFn,
useCurrentUser,
useMorphGroups,
@@ -50,8 +45,8 @@ export function Fixtures() {
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 'not-implemented': return <UnsupportedEchoMode />
case 'internal-error': return <DivideByZero />
case 'permission-error': return <PermissionError_ />
case 'permission-success': return <PermissionSuccess />
case 'context-current-user': return <ContextCurrentUser />
@@ -105,7 +100,7 @@ function Multiply() { const r = useRun(useMultiply, { x: 6, y: 7 }); return <Res
function NotFound() {
const { call } = useMizan()
const [error, setError] = useState<unknown>()
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
useEffect(() => { call('does_not_exist', {}).catch(setError) }, [call])
return <Result error={error} />
}
@@ -120,20 +115,16 @@ function AuthRequired() { const r = useRun(useWhoami); return <Result {...r} />
function StaffOnly() { const r = useRun(useStaffOnly); return <Result {...r} /> }
function SuperuserOnly() { const r = useRun(useSuperuserOnly); return <Result {...r} /> }
function VerifiedOnly() { const r = useRun(useVerifiedOnly); return <Result {...r} /> }
function NotImplemented() { const r = useRun(useNotImplementedFn); return <Result {...r} /> }
function InternalError() { const r = useRun(useBuggyFn); return <Result {...r} /> }
function UnsupportedEchoMode() { const r = useRun(useEchoTransform, { text: 'e2e-test', mode: 'rot13' }); return <Result {...r} /> }
function DivideByZero() { const r = useRun(useDivide, { numerator: 1, denominator: 0 }); return <Result {...r} /> }
function PermissionError_() { const r = useRun(usePermissionCheckFn, { secret: 'wrong' }); return <Result {...r} /> }
function PermissionSuccess() { const r = useRun(usePermissionCheckFn, { secret: 'open-sesame' }); return <Result {...r} /> }
function ContextCurrentUser() {
try {
const user = useCurrentUser()
if (user === null) return <div>loading context...</div>
return <pre data-testid="result">{JSON.stringify(user)}</pre>
} catch {
return <div>loading context...</div>
}
const user = useCurrentUser()
if (user === null) return <div>loading context...</div>
return <pre data-testid="result">{JSON.stringify(user)}</pre>
}
@@ -150,14 +141,16 @@ function MergeMorphInner() {
const layers = useMorphLayers()
const { mutate } = useSetMorphValue()
const [fired, setFired] = useState(false)
const [error, setError] = useState<unknown>()
useEffect(() => {
if (layers && groups && !fired) {
setFired(true)
mutate({ id: 1, value: 0.75 })
mutate({ id: 1, value: 0.75 }).catch(setError)
}
}, [layers, groups, fired, mutate])
if (error !== undefined) return <Result error={error} />
if (layers === null || groups === null) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify({ groups, layers })}</pre>
}

View File

@@ -1,9 +1,7 @@
/**
* mizan-fastapi e2e — Real Chromium → React with generated hooks → real FastAPI server.
*
* Mirrors examples/django-react-site/mizan.spec.ts minus channel/form tests
* (those features are Django-only — FastAPI projects use native equivalents
* or skip them entirely).
* Drives a real Chromium against the harness (React + generated hooks) talking
* to a real FastAPI server. Each test navigates to a URL hash that selects one
* fixture component, then reads that component's data-testid nodes.
*/
import { test, expect } from '@playwright/test'
@@ -104,14 +102,14 @@ test.describe('error codes from generated hooks', () => {
expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code)
})
test('useNotImplementedFn → NOT_IMPLEMENTED', async ({ page }) => {
test('useEchoTransform with an unregistered mode → NOT_IMPLEMENTED', async ({ page }) => {
await fixture(page, 'not-implemented')
const error = await getError(page)
expect(error!.type).toBe('MizanError')
expect(error!.code).toBe('NOT_IMPLEMENTED')
})
test('useBuggyFn → INTERNAL_ERROR', async ({ page }) => {
test('useDivide by zero → INTERNAL_ERROR', async ({ page }) => {
await fixture(page, 'internal-error')
const error = await getError(page)
expect(error!.type).toBe('MizanError')
@@ -160,10 +158,8 @@ test.describe('merge protocol', () => {
// The morphs context bundles two list slots:
// morph_groups: list[MorphGroupMeta] — {id, label, count}
// morph_layers: list[MorphLayer] — {id, group_id, label, value}
// set_morph_value returns MorphLayer. Server-side slot resolution
// (via mizan_core.type_utils.types_match_for_merge) must route to
// morph_layers and leave morph_groups intact. A kernel-side heuristic
// would have to guess between the two id-bearing list slots.
// set_morph_value returns MorphLayer, so the server resolves the slot
// by return type and the value lands in morph_layers.
const morphsFetches: string[] = []
page.on('request', (req) => {
const url = req.url()
@@ -172,21 +168,20 @@ test.describe('merge protocol', () => {
await page.goto(`${BASE}#merge-morph`)
await page.waitForFunction(() => {
// The fixture renders a plain "loading..." div until both slots
// arrive, so this node exists only once its content is full JSON.
const el = document.querySelector('[data-testid="result"]')
if (!el) return false
try {
const data = JSON.parse(el.textContent!)
return data.layers?.some((l: any) => l.id === 1 && l.value === 0.75)
} catch { return false }
const data = JSON.parse(el.textContent!)
return data.layers?.some((l: any) => l.id === 1 && l.value === 0.75)
}, { timeout: 5000 })
const result = await getResult(page)
const layer = result.layers.find((l: any) => l.id === 1)
expect(layer.value).toBe(0.75)
expect(layer.label).toBe('brow')
// Sibling slot is unchanged — the server didn't route MorphLayer into morph_groups.
expect(result.groups).toEqual([{ id: 1, label: 'face', count: 2 }])
// Initial mount fetches once; merge path must not trigger a refetch.
// Initial mount fetches once; the merge path must not trigger a refetch.
expect(morphsFetches.length).toBe(1)
})
})