Full test infrastructure, code audit fixes, and real E2E integration tests

Test infrastructure:
- Django standalone test runner (pytest-django, test settings, EmailUser model)
- React unit tests via Vitest with jsdom, jest compat layer, path aliases
- Playwright E2E tests using generated hooks in a real Chromium browser
- Docker Compose test backend (Django + Redis) for integration testing
- Desktop integration test app (PyWebView + Django + uvicorn)
- Makefile with test/test-django/test-react/test-integration targets

Library bugs found and fixed:
- hasJWT truthiness: undefined !== null was true, skipping session init
- process.env crash: CSR client referenced process.env in non-Node browsers
- baseUrl not forwarded: DjareaProvider didn't pass baseUrl to CSR client
- Relative URL handling: new URL() failed with relative base paths
- call() race condition: HTTP requests fired before CSRF cookie was set
- Session init await: added sessionRef promise so call() waits for session
- path_prefix on schema export: both export commands failed with URL reverse
- NullBooleanField removed: referenced field doesn't exist in Django 5.0+
- lru_cache on JWT settings: get_settings() now cached as intended
- Channel message routing: broadcasts now include channel name and params
- httpFunctionCall: fixed URL and request body format

Generator fixes:
- Removed 1,100 lines of REST/OpenAPI client generation (not part of Djarea)
- Generator now works for djarea-only projects without django-ninja REST APIs
- Generated DjangoContext now includes ChannelProvider when channels exist
- Fixed env var passthrough for schema export commands
- Deduplicated fetch logic into single runDjangoCommand helper

Test quality:
- Fixed 33 tautological Django tests with real assertions
- Found hidden bug: benchmark functions were never registered
- Found hidden bug: unicode lookalike test used plain ASCII
- Deleted worthless React unit tests (duplicates, shape checks, Zod-tests-Zod)
- Replaced jsdom integration tests with Playwright browser tests

Example apps:
- example/: Integration test backend with 33 server functions, 5 forms,
  4 channels covering auth variations, contexts, class-based ServerFunction,
  error codes, DjareaFormMixin, formsets, and JWT
- desktop/: PyWebView desktop app with file system access, SQLite CRUD,
  system introspection, and 39 real HTTP integration tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 01:17:48 -04:00
commit 4451ec24a1
179 changed files with 27699 additions and 0 deletions

View File

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

5
e2e/harness/index.html Normal file
View File

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

22
e2e/harness/package.json Normal file
View File

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

View File

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

View File

@@ -0,0 +1,264 @@
/**
* E2E Test Fixtures
*
* Each fixture uses GENERATED Djarea hooks (not raw call()).
* Playwright reads the DOM to verify behavior.
*
* URL hash selects the fixture: #echo, #add, #multiply, etc.
*/
import { useState, useEffect, useRef } from 'react'
// Generated typed hooks — the actual Djarea API
import {
DjangoContext,
useEcho,
useAdd,
useMultiply,
useWhoami,
useStaffOnly,
useSuperuserOnly,
useVerifiedOnly,
useNotImplementedFn,
useBuggyFn,
usePermissionCheckFn,
useCurrentUser,
DjangoError,
useDjarea,
} from './api/generated.django'
import { useContactForm, useLoginForm } from './api/generated.forms'
import { useChatChannel } from './api/generated.channels.hooks'
// ─── Fixture router ─────────────────────────────────────────────────────────
export function Fixtures() {
const [hash, setHash] = useState(window.location.hash.slice(1))
useEffect(() => {
const onHash = () => setHash(window.location.hash.slice(1))
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
switch (hash) {
case 'echo': return <Echo />
case 'add': return <Add />
case 'multiply': return <Multiply />
case 'not-found': return <NotFound />
case 'validation-error': return <ValidationError />
case 'auth-required': return <AuthRequired />
case 'staff-only': return <StaffOnly />
case 'superuser-only': return <SuperuserOnly />
case 'verified-only': return <VerifiedOnly />
case 'not-implemented': return <NotImplemented />
case 'internal-error': return <InternalError />
case 'permission-error': return <PermissionError_ />
case 'permission-success': return <PermissionSuccess />
case 'context-current-user': return <ContextCurrentUser />
case 'form-login-schema': return <FormLoginSchema />
case 'form-contact-schema': return <FormContactSchema />
case 'form-contact-submit': return <FormContactSubmit />
case 'channel-chat': return <ChannelChatFixture />
default: return <div data-testid="ready">Harness ready. Set #hash.</div>
}
}
// ─── Result helper ──────────────────────────────────────────────────────────
function Result({ data, error }: { data?: unknown; error?: unknown }) {
return (
<>
{data !== undefined && (
<pre data-testid="result">{JSON.stringify(data)}</pre>
)}
{error !== undefined && error !== null && (
<>
<div data-testid="error-type">
{error instanceof DjangoError ? 'DjangoError' : 'Error'}
</div>
<div data-testid="error-code">
{error instanceof DjangoError ? error.code : ''}
</div>
<pre data-testid="error-message">
{error instanceof Error ? error.message : String(error)}
</pre>
</>
)}
</>
)
}
// ─── Hook runner: calls a generated hook and renders result ─────────────────
function useRun<T>(hook: () => (input?: any) => Promise<T>, input?: any) {
const call = hook()
const [data, setData] = useState<T>()
const [error, setError] = useState<unknown>()
useEffect(() => {
call(input).then(setData).catch(setError)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { data, error }
}
// ─── Server function fixtures ───────────────────────────────────────────────
function Echo() {
const { data, error } = useRun(useEcho, { text: 'e2e-test' })
return <Result data={data} error={error} />
}
function Add() {
const { data, error } = useRun(useAdd, { a: 17, b: 25 })
return <Result data={data} error={error} />
}
function Multiply() {
const { data, error } = useRun(useMultiply, { x: 6, y: 7 })
return <Result data={data} error={error} />
}
function NotFound() {
// Deliberately call a non-existent function via the raw primitive
const { call } = useDjarea()
const [error, setError] = useState<unknown>()
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
return <Result error={error} />
}
function ValidationError() {
// Send wrong types to add (strings instead of numbers)
const call = useAdd()
const [error, setError] = useState<unknown>()
useEffect(() => { (call as any)({ a: 'not_a_number', b: 'also_not' }).catch(setError) }, [call])
return <Result error={error} />
}
function AuthRequired() {
const { data, error } = useRun(useWhoami)
return <Result data={data} error={error} />
}
function StaffOnly() {
const { data, error } = useRun(useStaffOnly)
return <Result data={data} error={error} />
}
function SuperuserOnly() {
const { data, error } = useRun(useSuperuserOnly)
return <Result data={data} error={error} />
}
function VerifiedOnly() {
const { data, error } = useRun(useVerifiedOnly)
return <Result data={data} error={error} />
}
function NotImplemented() {
const { data, error } = useRun(useNotImplementedFn)
return <Result data={data} error={error} />
}
function InternalError() {
const { data, error } = useRun(useBuggyFn)
return <Result data={data} error={error} />
}
function PermissionError_() {
const { data, error } = useRun(usePermissionCheckFn, { secret: 'wrong' })
return <Result data={data} error={error} />
}
function PermissionSuccess() {
const { data, error } = useRun(usePermissionCheckFn, { secret: 'open-sesame' })
return <Result data={data} error={error} />
}
// ─── Context fixtures ───────────────────────────────────────────────────────
function ContextCurrentUser() {
// useCurrentUser throws if context not loaded yet, so catch that
try {
const user = useCurrentUser()
return <pre data-testid="result">{JSON.stringify(user)}</pre>
} catch {
return <div>loading context...</div>
}
}
// ─── Form fixtures (using generated form hooks) ─────────────────────────────
function FormLoginSchema() {
const form = useLoginForm()
if (form.loading) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify(form.schema)}</pre>
}
function FormContactSchema() {
const form = useContactForm()
if (form.loading) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify(form.schema)}</pre>
}
function FormContactSubmit() {
const form = useContactForm()
const [result, setResult] = useState<unknown>()
const [submitted, setSubmitted] = useState(false)
useEffect(() => {
if (!form.loading && !submitted) {
form.set('name', 'Test User')
form.set('email', 'test@example.com')
form.set('message', 'Hello from e2e')
setSubmitted(true)
}
}, [form.loading, submitted, form])
useEffect(() => {
if (submitted && !result) {
form.submit().then(setResult)
}
}, [submitted, result, form])
if (!result) return <div>loading...</div>
return <pre data-testid="result">{JSON.stringify(result)}</pre>
}
// ─── Channel fixtures ───────────────────────────────────────────────────────
function ChannelChatFixture() {
// DjangoContext already includes ChannelProvider
return <ChannelChat />
}
function ChannelChat() {
const chat = useChatChannel({ room: 'e2e' })
const [sent, setSent] = useState(false)
const prevStatus = useRef(chat.status)
useEffect(() => {
// Send once when status transitions to 'connected' (meaning subscribed)
// The hook maps subscribed → 'connected', but we need to wait for it
// to go through 'connecting' first (before subscription is confirmed)
const wasConnecting = prevStatus.current === 'connecting'
prevStatus.current = chat.status
if (wasConnecting && chat.status === 'connected' && !sent) {
chat.send({ text: 'hello from e2e' })
setSent(true)
}
}, [chat.status, sent, chat])
return (
<div>
<div data-testid="channel-status">{chat.status}</div>
<div data-testid="channel-message-count">{chat.messages.length}</div>
{chat.messages.length > 0 && (
<pre data-testid="channel-last-message">
{JSON.stringify(chat.messages[chat.messages.length - 1])}
</pre>
)}
</div>
)
}

13
e2e/harness/src/main.tsx Normal file
View File

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

11
e2e/harness/tsconfig.json Normal file
View File

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

View File

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