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,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(
() => {