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:
90
e2e/harness/src/api/index.ts
Normal file
90
e2e/harness/src/api/index.ts
Normal 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'
|
||||
264
e2e/harness/src/fixtures.tsx
Normal file
264
e2e/harness/src/fixtures.tsx
Normal 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
13
e2e/harness/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { DjangoContext } from './api/generated.django'
|
||||
import { Fixtures } from './fixtures'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DjangoContext baseUrl="/api/djarea">
|
||||
<Fixtures />
|
||||
</DjangoContext>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
Reference in New Issue
Block a user