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)