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:
0
desktop/backend/__init__.py
Normal file
0
desktop/backend/__init__.py
Normal file
6
desktop/backend/apps.py
Normal file
6
desktop/backend/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DesktopBackendConfig(AppConfig):
|
||||
name = "backend"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
13
desktop/backend/asgi.py
Normal file
13
desktop/backend/asgi.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
|
||||
import backend.djarea_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
413
desktop/backend/djarea_clients.py
Normal file
413
desktop/backend/djarea_clients.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Desktop RPC server functions.
|
||||
|
||||
Tests Djarea'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 os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from djarea.channels import ReactChannel
|
||||
from djarea.setup.registry import register
|
||||
from djarea.channels import register as register_channel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System Info
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SystemInfoOutput(BaseModel):
|
||||
os_name: str
|
||||
os_version: str
|
||||
python_version: str
|
||||
hostname: str
|
||||
username: str
|
||||
home_dir: str
|
||||
cwd: str
|
||||
cpu_count: int
|
||||
djarea_version: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||
import djarea
|
||||
|
||||
return SystemInfoOutput(
|
||||
os_name=platform.system(),
|
||||
os_version=platform.version(),
|
||||
python_version=sys.version.split()[0],
|
||||
hostname=platform.node(),
|
||||
username=os.getenv("USER", os.getenv("USERNAME", "unknown")),
|
||||
home_dir=str(Path.home()),
|
||||
cwd=os.getcwd(),
|
||||
cpu_count=os.cpu_count() or 1,
|
||||
djarea_version=getattr(djarea, "__version__", "dev"),
|
||||
)
|
||||
|
||||
|
||||
register(system_info, "system_info")
|
||||
|
||||
|
||||
class DiskUsageOutput(BaseModel):
|
||||
path: str
|
||||
total_gb: float
|
||||
used_gb: float
|
||||
free_gb: float
|
||||
percent_used: float
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def disk_usage(request: HttpRequest, path: str = "/") -> DiskUsageOutput:
|
||||
usage = shutil.disk_usage(path)
|
||||
return DiskUsageOutput(
|
||||
path=path,
|
||||
total_gb=round(usage.total / (1024**3), 2),
|
||||
used_gb=round(usage.used / (1024**3), 2),
|
||||
free_gb=round(usage.free / (1024**3), 2),
|
||||
percent_used=round(usage.used / usage.total * 100, 1),
|
||||
)
|
||||
|
||||
|
||||
register(disk_usage, "disk_usage")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# File System
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FileEntry(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
is_dir: bool
|
||||
size: int
|
||||
modified: str
|
||||
|
||||
|
||||
class ListFilesOutput(BaseModel):
|
||||
directory: str
|
||||
entries: list[FileEntry]
|
||||
parent: str | None
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
||||
dir_path = Path(directory).expanduser().resolve()
|
||||
|
||||
if not dir_path.is_dir():
|
||||
raise ValueError(f"Not a directory: {dir_path}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
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):
|
||||
continue
|
||||
except PermissionError:
|
||||
raise PermissionError(f"Cannot read directory: {dir_path}")
|
||||
|
||||
parent = str(dir_path.parent) if dir_path.parent != dir_path else None
|
||||
|
||||
return ListFilesOutput(
|
||||
directory=str(dir_path),
|
||||
entries=entries,
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
|
||||
register(list_files, "list_files")
|
||||
|
||||
|
||||
class FileContentOutput(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
size: int
|
||||
modified: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def read_file(request: HttpRequest, path: str) -> FileContentOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
if not file_path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
stat = file_path.stat()
|
||||
|
||||
# Safety: limit to 1MB text files
|
||||
if stat.st_size > 1_048_576:
|
||||
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError(f"Not a text file: {file_path}")
|
||||
|
||||
return FileContentOutput(
|
||||
path=str(file_path),
|
||||
content=content,
|
||||
size=stat.st_size,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
register(read_file, "read_file")
|
||||
|
||||
|
||||
class WriteFileOutput(BaseModel):
|
||||
path: str
|
||||
size: int
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
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}")
|
||||
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
return WriteFileOutput(path=str(file_path), size=len(content.encode("utf-8")))
|
||||
|
||||
|
||||
register(write_file, "write_file")
|
||||
|
||||
|
||||
class DeleteFileOutput(BaseModel):
|
||||
path: str
|
||||
deleted: bool
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def delete_file(request: HttpRequest, path: str) -> DeleteFileOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
home = Path.home()
|
||||
if not str(file_path).startswith(str(home)):
|
||||
raise PermissionError(f"Can only delete files within home directory: {home}")
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return DeleteFileOutput(path=str(file_path), deleted=True)
|
||||
|
||||
return DeleteFileOutput(path=str(file_path), deleted=False)
|
||||
|
||||
|
||||
register(delete_file, "delete_file")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notes CRUD (SQLite)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class NoteOutput(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
pinned: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class NoteListOutput(BaseModel):
|
||||
notes: list[NoteOutput]
|
||||
count: int
|
||||
|
||||
|
||||
def _note_to_output(note) -> NoteOutput:
|
||||
return NoteOutput(
|
||||
id=note.id,
|
||||
title=note.title,
|
||||
content=note.content,
|
||||
pinned=note.pinned,
|
||||
created_at=note.created_at.isoformat(),
|
||||
updated_at=note.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def list_notes(request: HttpRequest) -> NoteListOutput:
|
||||
from backend.models import Note
|
||||
|
||||
notes = Note.objects.all()
|
||||
return NoteListOutput(
|
||||
notes=[_note_to_output(n) for n in notes],
|
||||
count=notes.count(),
|
||||
)
|
||||
|
||||
|
||||
register(list_notes, "list_notes")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def create_note(request: HttpRequest, title: str, content: str = "", pinned: bool = False) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(create_note, "create_note")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def get_note(request: HttpRequest, id: int) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
except Note.DoesNotExist:
|
||||
raise ValueError(f"Note {id} not found")
|
||||
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(get_note, "get_note")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def update_note(
|
||||
request: HttpRequest,
|
||||
id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
pinned: bool | None = None,
|
||||
) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
except Note.DoesNotExist:
|
||||
raise ValueError(f"Note {id} not found")
|
||||
|
||||
if title is not None:
|
||||
note.title = title
|
||||
if content is not None:
|
||||
note.content = content
|
||||
if pinned is not None:
|
||||
note.pinned = pinned
|
||||
|
||||
note.save()
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(update_note, "update_note")
|
||||
|
||||
|
||||
class DeleteNoteOutput(BaseModel):
|
||||
id: int
|
||||
deleted: bool
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
note.delete()
|
||||
return DeleteNoteOutput(id=id, deleted=True)
|
||||
except Note.DoesNotExist:
|
||||
return DeleteNoteOutput(id=id, deleted=False)
|
||||
|
||||
|
||||
register(delete_note, "delete_note")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channels — Real-time Desktop Events
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AppStatusChannel(ReactChannel):
|
||||
"""Push app status updates to the UI (uptime, memory, etc.)."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
uptime_seconds: float
|
||||
memory_mb: float
|
||||
note_count: int
|
||||
timestamp: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True # Desktop app, no auth needed
|
||||
|
||||
def group(self, params=None):
|
||||
return "app_status"
|
||||
|
||||
|
||||
register_channel(AppStatusChannel, "app_status")
|
||||
|
||||
|
||||
class NotesChannel(ReactChannel):
|
||||
"""Push notifications when notes are modified."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
action: str # "created", "updated", "deleted"
|
||||
note_id: int
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "notes_updates"
|
||||
|
||||
|
||||
register_channel(NotesChannel, "notes_updates")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# App Lifecycle
|
||||
# =============================================================================
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
class AppInfoOutput(BaseModel):
|
||||
app_name: str
|
||||
uptime_seconds: float
|
||||
db_path: str
|
||||
pid: int
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def app_info(request: HttpRequest) -> AppInfoOutput:
|
||||
from django.conf import settings
|
||||
|
||||
return AppInfoOutput(
|
||||
app_name="Djarea Desktop",
|
||||
uptime_seconds=round(time.time() - _start_time, 2),
|
||||
db_path=str(settings.DATABASES["default"]["NAME"]),
|
||||
pid=os.getpid(),
|
||||
)
|
||||
|
||||
|
||||
register(app_info, "app_info")
|
||||
15
desktop/backend/models.py
Normal file
15
desktop/backend/models.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField(blank=True, default="")
|
||||
pinned = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-pinned", "-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
49
desktop/backend/settings.py
Normal file
49
desktop/backend/settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Django settings for the Djarea desktop integration test app.
|
||||
|
||||
Runs entirely local: SQLite database, in-memory channel layer,
|
||||
no external services required.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SECRET_KEY = "desktop-app-local-only-secret-key"
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"backend",
|
||||
]
|
||||
|
||||
MIDDLEWARE = []
|
||||
|
||||
ROOT_URLCONF = "backend.urls"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": os.path.join(BASE_DIR, "app.db"),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
ASGI_APPLICATION = "backend.asgi.application"
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||
},
|
||||
}
|
||||
|
||||
# Serve the built frontend
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [os.path.join(BASE_DIR, "frontend", "dist")]
|
||||
|
||||
# No auth, no CSRF — local desktop app
|
||||
CSRF_COOKIE_HTTPONLY = False
|
||||
34
desktop/backend/urls.py
Normal file
34
desktop/backend/urls.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.urls import include, path, re_path
|
||||
from django.http import HttpResponse, HttpResponseNotFound
|
||||
from pathlib import Path
|
||||
|
||||
DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
|
||||
CONTENT_TYPES = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
".woff2": "font/woff2",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def serve_dist(request, path="index.html"):
|
||||
file_path = (DIST_DIR / path).resolve()
|
||||
|
||||
if not str(file_path).startswith(str(DIST_DIR)) or not file_path.is_file():
|
||||
return HttpResponseNotFound()
|
||||
|
||||
ct = CONTENT_TYPES.get(file_path.suffix, "application/octet-stream")
|
||||
return HttpResponse(file_path.read_bytes(), content_type=ct)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
re_path(r"^(?P<path>assets/.+)$", serve_dist),
|
||||
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
|
||||
path("", serve_dist),
|
||||
]
|
||||
Reference in New Issue
Block a user