From 4451ec24a1a59e75dad58aba2ac61a1f096b3261 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 31 Mar 2026 01:17:48 -0400 Subject: [PATCH] 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) --- .gitea/workflows/publish-django.yaml | 32 + .gitea/workflows/publish-react.yaml | 36 + .gitignore | 32 + Dockerfile.test | 21 + Makefile | 46 + README.md | 22 + desktop/app.py | 96 ++ desktop/backend/__init__.py | 0 desktop/backend/apps.py | 6 + desktop/backend/asgi.py | 13 + desktop/backend/djarea_clients.py | 413 ++++++ desktop/backend/models.py | 15 + desktop/backend/settings.py | 49 + desktop/backend/urls.py | 34 + desktop/frontend/index.html | 16 + desktop/frontend/package.json | 21 + desktop/frontend/src/App.tsx | 215 +++ desktop/frontend/src/main.tsx | 4 + desktop/frontend/tsconfig.json | 11 + desktop/frontend/vite.config.ts | 12 + desktop/manage.py | 8 + desktop/pyproject.toml | 25 + desktop/tests/__init__.py | 0 desktop/tests/conftest.py | 7 + desktop/tests/test_desktop_rpc.py | 173 +++ desktop/tests/test_notes.py | 142 ++ desktop/tests/test_system.py | 162 +++ django/.gitignore | 4 + django/README.md | 29 + django/pyproject.toml | 42 + django/src/djarea/__init__.py | 176 +++ django/src/djarea/_vendor/__init__.py | 0 django/src/djarea/_vendor/app_visitor.py | 91 ++ django/src/djarea/channels/__init__.py | 527 +++++++ django/src/djarea/channels/connection.py | 482 +++++++ django/src/djarea/channels/push.py | 150 ++ django/src/djarea/client/__init__.py | 60 + django/src/djarea/client/executor.py | 478 +++++++ django/src/djarea/client/function.py | 694 ++++++++++ django/src/djarea/client/jwt.py | 44 + django/src/djarea/export/__init__.py | 299 ++++ django/src/djarea/forms/__init__.py | 623 +++++++++ django/src/djarea/forms/formset_utils.py | 16 + django/src/djarea/forms/schema_utils.py | 187 +++ django/src/djarea/forms/schemas.py | 103 ++ django/src/djarea/forms/validation_utils.py | 72 + .../djarea/integrations/allauth/__init__.py | 25 + .../djarea/integrations/allauth/contexts.py | 115 ++ .../src/djarea/integrations/allauth/forms.py | 400 ++++++ django/src/djarea/jwt/__init__.py | 70 + django/src/djarea/jwt/functions.py | 97 ++ django/src/djarea/jwt/security.py | 64 + django/src/djarea/jwt/settings.py | 118 ++ django/src/djarea/jwt/tokens.py | 245 ++++ django/src/djarea/management/__init__.py | 0 .../djarea/management/commands/__init__.py | 0 .../commands/export_channels_schema.py | 35 + .../commands/export_djarea_schema.py | 51 + django/src/djarea/setup/__init__.py | 69 + django/src/djarea/setup/discovery.py | 90 ++ django/src/djarea/setup/registry.py | 316 +++++ django/src/djarea/setup/settings.py | 36 + django/src/djarea/tests/__init__.py | 0 django/src/djarea/tests/test_auth.py | 531 +++++++ django/src/djarea/tests/test_benchmarks.py | 548 ++++++++ django/src/djarea/tests/test_channels.py | 1170 ++++++++++++++++ django/src/djarea/tests/test_core.py | 1081 +++++++++++++++ django/src/djarea/tests/test_pentest.py | 1224 +++++++++++++++++ django/src/djarea/tests/test_security.py | 1095 +++++++++++++++ django/src/djarea/urls.py | 40 + django/tests/__init__.py | 0 django/tests/models.py | 40 + django/tests/settings.py | 46 + django/tests/urls.py | 5 + docker-compose.test.yml | 17 + e2e/djarea.spec.ts | 186 +++ e2e/harness/django.config.mjs | 22 + e2e/harness/index.html | 5 + e2e/harness/package.json | 22 + e2e/harness/src/api/index.ts | 90 ++ e2e/harness/src/fixtures.tsx | 264 ++++ e2e/harness/src/main.tsx | 13 + e2e/harness/tsconfig.json | 11 + e2e/harness/vite.config.ts | 30 + example/manage.py | 10 + example/testapp/__init__.py | 0 example/testapp/apps.py | 9 + example/testapp/asgi.py | 14 + example/testapp/djarea_clients.py | 393 ++++++ example/testapp/models.py | 29 + example/testapp/settings.py | 76 + example/testapp/urls.py | 5 + package.json | 18 + playwright.config.ts | 14 + react/.gitignore | 2 + react/README.md | 26 + react/package.json | 85 ++ react/src/__tests__/context.test.tsx | 314 +++++ react/src/__tests__/errors.test.ts | 214 +++ react/src/__tests__/forms.test.tsx | 362 +++++ react/src/__tests__/integration.test.tsx | 824 +++++++++++ react/src/allauth/adapters/router.ts | 11 + react/src/allauth/api.ts | 309 +++++ .../src/allauth/components/AllauthRouter.tsx | 220 +++ react/src/allauth/components/AllauthUI.tsx | 447 ++++++ react/src/allauth/components/AuthCard.tsx | 85 ++ .../src/allauth/components/AuthDjangoForm.tsx | 326 +++++ react/src/allauth/components/AuthForm.tsx | 99 ++ react/src/allauth/components/AuthFormPage.tsx | 127 ++ react/src/allauth/components/PasskeyLogin.tsx | 103 ++ react/src/allauth/components/ProviderList.tsx | 56 + react/src/allauth/components/index.ts | 41 + .../components/settings/AuthSettings.tsx | 79 ++ .../settings/ConnectionsSection.tsx | 87 ++ .../components/settings/EmailsSection.tsx | 120 ++ .../components/settings/MFASection.tsx | 171 +++ .../components/settings/PasskeysSection.tsx | 103 ++ .../components/settings/PasswordSection.tsx | 54 + .../components/settings/ProfileSection.tsx | 22 + .../components/settings/SessionsSection.tsx | 88 ++ .../settings/SettingsComponents.tsx | 76 + .../src/allauth/components/settings/index.ts | 20 + .../allauth/components/views/LoginView.tsx | 75 + .../components/views/MFAChooserView.tsx | 137 ++ .../components/views/MFARecoveryCodesView.tsx | 51 + .../allauth/components/views/MFATOTPView.tsx | 51 + .../components/views/MFAWebAuthnView.tsx | 113 ++ .../allauth/components/views/SignupView.tsx | 42 + react/src/allauth/components/views/index.ts | 6 + react/src/allauth/config.ts | 67 + react/src/allauth/contexts/APIContext.tsx | 72 + react/src/allauth/contexts/AllauthContext.tsx | 116 ++ react/src/allauth/contexts/AuthContext.tsx | 153 +++ react/src/allauth/contexts/ConfigContext.tsx | 29 + react/src/allauth/contexts/RouterContext.tsx | 31 + react/src/allauth/contexts/StylesContext.tsx | 49 + react/src/allauth/contexts/index.ts | 6 + react/src/allauth/defines.ts | 71 + react/src/allauth/events.ts | 51 + react/src/allauth/hydration.ts | 48 + react/src/allauth/index.ts | 213 +++ react/src/allauth/nextjs.tsx | 96 ++ react/src/allauth/routing.tsx | 110 ++ react/src/allauth/styles/types.ts | 122 ++ react/src/allauth/types.ts | 546 ++++++++ .../src/channels/__tests__/connection.test.ts | 165 +++ react/src/channels/__tests__/context.test.tsx | 207 +++ react/src/channels/__tests__/hooks.test.tsx | 158 +++ react/src/channels/connection.ts | 299 ++++ react/src/channels/context.tsx | 102 ++ react/src/channels/hooks.ts | 256 ++++ react/src/channels/index.ts | 76 + react/src/channels/types.ts | 84 ++ react/src/client/AuthContext.tsx | 142 ++ react/src/client/RouterContext.tsx | 43 + react/src/client/index.ts | 585 ++++++++ react/src/client/nextjs.tsx | 72 + react/src/client/react.ts | 63 + react/src/client/routing.tsx | 74 + react/src/client/types.ts | 66 + react/src/context.tsx | 629 +++++++++ react/src/errors.ts | 107 ++ react/src/forms.ts | 1163 ++++++++++++++++ react/src/generator/cli.mjs | 283 ++++ react/src/generator/lib/channels.mjs | 155 +++ react/src/generator/lib/djarea.mjs | 770 +++++++++++ react/src/generator/lib/fetch.mjs | 88 ++ react/src/generator/lib/index.mjs | 153 +++ react/src/index.ts | 115 ++ react/src/jwt/JWTContext.tsx | 235 ++++ react/src/jwt/__tests__/JWTContext.test.tsx | 152 ++ react/src/jwt/__tests__/contract.test.ts | 79 ++ react/src/jwt/__tests__/hooks.test.tsx | 34 + react/src/jwt/index.ts | 79 ++ react/src/testing.ts | 42 + react/tsconfig.build.json | 23 + react/tsconfig.json | 14 + react/vitest.config.ts | 27 + react/vitest.setup.ts | 6 + 179 files changed, 27699 insertions(+) create mode 100644 .gitea/workflows/publish-django.yaml create mode 100644 .gitea/workflows/publish-react.yaml create mode 100644 .gitignore create mode 100644 Dockerfile.test create mode 100644 Makefile create mode 100644 README.md create mode 100644 desktop/app.py create mode 100644 desktop/backend/__init__.py create mode 100644 desktop/backend/apps.py create mode 100644 desktop/backend/asgi.py create mode 100644 desktop/backend/djarea_clients.py create mode 100644 desktop/backend/models.py create mode 100644 desktop/backend/settings.py create mode 100644 desktop/backend/urls.py create mode 100644 desktop/frontend/index.html create mode 100644 desktop/frontend/package.json create mode 100644 desktop/frontend/src/App.tsx create mode 100644 desktop/frontend/src/main.tsx create mode 100644 desktop/frontend/tsconfig.json create mode 100644 desktop/frontend/vite.config.ts create mode 100644 desktop/manage.py create mode 100644 desktop/pyproject.toml create mode 100644 desktop/tests/__init__.py create mode 100644 desktop/tests/conftest.py create mode 100644 desktop/tests/test_desktop_rpc.py create mode 100644 desktop/tests/test_notes.py create mode 100644 desktop/tests/test_system.py create mode 100644 django/.gitignore create mode 100644 django/README.md create mode 100644 django/pyproject.toml create mode 100644 django/src/djarea/__init__.py create mode 100644 django/src/djarea/_vendor/__init__.py create mode 100644 django/src/djarea/_vendor/app_visitor.py create mode 100644 django/src/djarea/channels/__init__.py create mode 100644 django/src/djarea/channels/connection.py create mode 100644 django/src/djarea/channels/push.py create mode 100644 django/src/djarea/client/__init__.py create mode 100644 django/src/djarea/client/executor.py create mode 100644 django/src/djarea/client/function.py create mode 100644 django/src/djarea/client/jwt.py create mode 100644 django/src/djarea/export/__init__.py create mode 100644 django/src/djarea/forms/__init__.py create mode 100644 django/src/djarea/forms/formset_utils.py create mode 100644 django/src/djarea/forms/schema_utils.py create mode 100644 django/src/djarea/forms/schemas.py create mode 100644 django/src/djarea/forms/validation_utils.py create mode 100644 django/src/djarea/integrations/allauth/__init__.py create mode 100644 django/src/djarea/integrations/allauth/contexts.py create mode 100644 django/src/djarea/integrations/allauth/forms.py create mode 100644 django/src/djarea/jwt/__init__.py create mode 100644 django/src/djarea/jwt/functions.py create mode 100644 django/src/djarea/jwt/security.py create mode 100644 django/src/djarea/jwt/settings.py create mode 100644 django/src/djarea/jwt/tokens.py create mode 100644 django/src/djarea/management/__init__.py create mode 100644 django/src/djarea/management/commands/__init__.py create mode 100644 django/src/djarea/management/commands/export_channels_schema.py create mode 100644 django/src/djarea/management/commands/export_djarea_schema.py create mode 100644 django/src/djarea/setup/__init__.py create mode 100644 django/src/djarea/setup/discovery.py create mode 100644 django/src/djarea/setup/registry.py create mode 100644 django/src/djarea/setup/settings.py create mode 100644 django/src/djarea/tests/__init__.py create mode 100644 django/src/djarea/tests/test_auth.py create mode 100644 django/src/djarea/tests/test_benchmarks.py create mode 100644 django/src/djarea/tests/test_channels.py create mode 100644 django/src/djarea/tests/test_core.py create mode 100644 django/src/djarea/tests/test_pentest.py create mode 100644 django/src/djarea/tests/test_security.py create mode 100644 django/src/djarea/urls.py create mode 100644 django/tests/__init__.py create mode 100644 django/tests/models.py create mode 100644 django/tests/settings.py create mode 100644 django/tests/urls.py create mode 100644 docker-compose.test.yml create mode 100644 e2e/djarea.spec.ts create mode 100644 e2e/harness/django.config.mjs create mode 100644 e2e/harness/index.html create mode 100644 e2e/harness/package.json create mode 100644 e2e/harness/src/api/index.ts create mode 100644 e2e/harness/src/fixtures.tsx create mode 100644 e2e/harness/src/main.tsx create mode 100644 e2e/harness/tsconfig.json create mode 100644 e2e/harness/vite.config.ts create mode 100644 example/manage.py create mode 100644 example/testapp/__init__.py create mode 100644 example/testapp/apps.py create mode 100644 example/testapp/asgi.py create mode 100644 example/testapp/djarea_clients.py create mode 100644 example/testapp/models.py create mode 100644 example/testapp/settings.py create mode 100644 example/testapp/urls.py create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 react/.gitignore create mode 100644 react/README.md create mode 100644 react/package.json create mode 100644 react/src/__tests__/context.test.tsx create mode 100644 react/src/__tests__/errors.test.ts create mode 100644 react/src/__tests__/forms.test.tsx create mode 100644 react/src/__tests__/integration.test.tsx create mode 100644 react/src/allauth/adapters/router.ts create mode 100644 react/src/allauth/api.ts create mode 100644 react/src/allauth/components/AllauthRouter.tsx create mode 100644 react/src/allauth/components/AllauthUI.tsx create mode 100644 react/src/allauth/components/AuthCard.tsx create mode 100644 react/src/allauth/components/AuthDjangoForm.tsx create mode 100644 react/src/allauth/components/AuthForm.tsx create mode 100644 react/src/allauth/components/AuthFormPage.tsx create mode 100644 react/src/allauth/components/PasskeyLogin.tsx create mode 100644 react/src/allauth/components/ProviderList.tsx create mode 100644 react/src/allauth/components/index.ts create mode 100644 react/src/allauth/components/settings/AuthSettings.tsx create mode 100644 react/src/allauth/components/settings/ConnectionsSection.tsx create mode 100644 react/src/allauth/components/settings/EmailsSection.tsx create mode 100644 react/src/allauth/components/settings/MFASection.tsx create mode 100644 react/src/allauth/components/settings/PasskeysSection.tsx create mode 100644 react/src/allauth/components/settings/PasswordSection.tsx create mode 100644 react/src/allauth/components/settings/ProfileSection.tsx create mode 100644 react/src/allauth/components/settings/SessionsSection.tsx create mode 100644 react/src/allauth/components/settings/SettingsComponents.tsx create mode 100644 react/src/allauth/components/settings/index.ts create mode 100644 react/src/allauth/components/views/LoginView.tsx create mode 100644 react/src/allauth/components/views/MFAChooserView.tsx create mode 100644 react/src/allauth/components/views/MFARecoveryCodesView.tsx create mode 100644 react/src/allauth/components/views/MFATOTPView.tsx create mode 100644 react/src/allauth/components/views/MFAWebAuthnView.tsx create mode 100644 react/src/allauth/components/views/SignupView.tsx create mode 100644 react/src/allauth/components/views/index.ts create mode 100644 react/src/allauth/config.ts create mode 100644 react/src/allauth/contexts/APIContext.tsx create mode 100644 react/src/allauth/contexts/AllauthContext.tsx create mode 100644 react/src/allauth/contexts/AuthContext.tsx create mode 100644 react/src/allauth/contexts/ConfigContext.tsx create mode 100644 react/src/allauth/contexts/RouterContext.tsx create mode 100644 react/src/allauth/contexts/StylesContext.tsx create mode 100644 react/src/allauth/contexts/index.ts create mode 100644 react/src/allauth/defines.ts create mode 100644 react/src/allauth/events.ts create mode 100644 react/src/allauth/hydration.ts create mode 100644 react/src/allauth/index.ts create mode 100644 react/src/allauth/nextjs.tsx create mode 100644 react/src/allauth/routing.tsx create mode 100644 react/src/allauth/styles/types.ts create mode 100644 react/src/allauth/types.ts create mode 100644 react/src/channels/__tests__/connection.test.ts create mode 100644 react/src/channels/__tests__/context.test.tsx create mode 100644 react/src/channels/__tests__/hooks.test.tsx create mode 100644 react/src/channels/connection.ts create mode 100644 react/src/channels/context.tsx create mode 100644 react/src/channels/hooks.ts create mode 100644 react/src/channels/index.ts create mode 100644 react/src/channels/types.ts create mode 100644 react/src/client/AuthContext.tsx create mode 100644 react/src/client/RouterContext.tsx create mode 100644 react/src/client/index.ts create mode 100644 react/src/client/nextjs.tsx create mode 100644 react/src/client/react.ts create mode 100644 react/src/client/routing.tsx create mode 100644 react/src/client/types.ts create mode 100644 react/src/context.tsx create mode 100644 react/src/errors.ts create mode 100644 react/src/forms.ts create mode 100755 react/src/generator/cli.mjs create mode 100644 react/src/generator/lib/channels.mjs create mode 100644 react/src/generator/lib/djarea.mjs create mode 100644 react/src/generator/lib/fetch.mjs create mode 100644 react/src/generator/lib/index.mjs create mode 100644 react/src/index.ts create mode 100644 react/src/jwt/JWTContext.tsx create mode 100644 react/src/jwt/__tests__/JWTContext.test.tsx create mode 100644 react/src/jwt/__tests__/contract.test.ts create mode 100644 react/src/jwt/__tests__/hooks.test.tsx create mode 100644 react/src/jwt/index.ts create mode 100644 react/src/testing.ts create mode 100644 react/tsconfig.build.json create mode 100644 react/tsconfig.json create mode 100644 react/vitest.config.ts create mode 100644 react/vitest.setup.ts diff --git a/.gitea/workflows/publish-django.yaml b/.gitea/workflows/publish-django.yaml new file mode 100644 index 0000000..cf63f39 --- /dev/null +++ b/.gitea/workflows/publish-django.yaml @@ -0,0 +1,32 @@ +name: Publish Django package to PyPI + +on: + push: + tags: + - 'django/v*' + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: django + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build tools + run: pip install build twine + + - name: Build package + run: python -m build + + - name: Publish to Gitea PyPI + env: + TWINE_REPOSITORY_URL: ${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/pypi + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PUBLISH_TOKEN }} + run: twine upload dist/* diff --git a/.gitea/workflows/publish-react.yaml b/.gitea/workflows/publish-react.yaml new file mode 100644 index 0000000..98b9be4 --- /dev/null +++ b/.gitea/workflows/publish-react.yaml @@ -0,0 +1,36 @@ +name: Publish React package to npm + +on: + push: + tags: + - 'react/v*' + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: react + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Configure Gitea npm registry + env: + REGISTRY_URL: ${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/npm/ + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + run: | + npm config set @rythazhur:registry "${REGISTRY_URL}" + npm config set -- "${REGISTRY_URL#https:}:_authToken" "${PUBLISH_TOKEN}" + + - name: Publish + run: npm publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab5b1c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.pyc +*.egg-info/ +.venv/ +*.db +uv.lock + +# Node +node_modules/ +dist/ +package-lock.json + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ + +# IDE +.idea/ +.vscode/ + +# Build artifacts +desktop/frontend/dist/ +e2e/harness/src/api/generated.* +e2e/harness/test-results/ + +# Env +.env +.env.* +*.pem +*.key diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..d976219 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install djarea from local source with channels support +COPY django/ /app/django/ +RUN pip install --no-cache-dir /app/django[channels] daphne + +# Copy example app +COPY example/ /app/example/ + +WORKDIR /app/example + +EXPOSE 8000 + +CMD ["sh", "-c", "python manage.py migrate --run-syncdb && daphne -b 0.0.0.0 -p 8000 testapp.asgi:application"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..15e5922 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: install test test-django test-react test-integration docker-up docker-down clean + +# ─── Setup ─────────────────────────────────────────────────────────────────── + +install: + cd django && pip install -e ".[dev,channels]" + cd react && npm install + +# ─── Unit Tests ────────────────────────────────────────────────────────────── + +test: test-django test-react + +test-django: + cd django && pytest + +test-react: + cd react && npm test + +# ─── Integration Tests ────────────────────────────────────────────────────── + +test-integration: docker-up + @echo "Waiting for backend..." + @timeout 30 sh -c 'until curl -sf http://localhost:8000/api/djarea/session/ > /dev/null 2>&1; do sleep 1; done' + cd react && npm run test:integration + @$(MAKE) docker-down + +# ─── Docker ────────────────────────────────────────────────────────────────── + +docker-up: + docker compose -f docker-compose.test.yml up -d --build + @echo "Backend starting at http://localhost:8000" + +docker-down: + docker compose -f docker-compose.test.yml down + +# ─── All ───────────────────────────────────────────────────────────────────── + +test-all: test test-integration + +# ─── Cleanup ───────────────────────────────────────────────────────────────── + +clean: + docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true + rm -rf django/src/djarea.egg-info django/dist django/build + rm -rf react/dist react/node_modules + rm -f example/db.sqlite3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..36d212e --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# djarea + +Django + React server functions framework. + +| Package | Path | Registry | +|---------|------|----------| +| `djarea` (Python) | `django/` | PyPI / git | +| `djarea` (TypeScript) | `react/` | npm / git | + +## Installation + +```bash +# Python +uv add "djarea[channels,allauth] @ git+https://git.impactsoundworks.com/isw/djarea.git#subdirectory=django" + +# TypeScript +npm install djarea@git+https://git.impactsoundworks.com/isw/djarea.git#workspace=react +``` + +## Quick Start + +See [django/README.md](django/README.md) and [react/README.md](react/README.md). diff --git a/desktop/app.py b/desktop/app.py new file mode 100644 index 0000000..604f858 --- /dev/null +++ b/desktop/app.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +""" +Djarea 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 Djarea server functions. +""" + +import os +import sys +import threading +import time + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") + +# Work around Qt WebEngine GPU crashes on some systems +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 + + call_command("migrate", "--run-syncdb", verbosity=0) + + import uvicorn + + uvicorn.run( + "backend.asgi:application", + host=host, + port=port, + log_level="warning", + ) + + +def wait_for_server(url: str, timeout: float = 10.0): + """Poll until the server responds.""" + from urllib.request import urlopen + from urllib.error import URLError + + deadline = time.time() + timeout + while time.time() < deadline: + try: + urlopen(url, timeout=1) + return True + except (URLError, OSError): + time.sleep(0.1) + return False + + +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/djarea/session/"): + print("ERROR: Django server failed to start", file=sys.stderr) + sys.exit(1) + + 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 + return + + # Open native window + import webview + + window = webview.create_window( + title="Djarea Desktop", + url=base_url, + width=1024, + height=768, + min_size=(640, 480), + ) + webview.start() + + +if __name__ == "__main__": + main() diff --git a/desktop/backend/__init__.py b/desktop/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/desktop/backend/apps.py b/desktop/backend/apps.py new file mode 100644 index 0000000..582ff19 --- /dev/null +++ b/desktop/backend/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DesktopBackendConfig(AppConfig): + name = "backend" + default_auto_field = "django.db.models.BigAutoField" diff --git a/desktop/backend/asgi.py b/desktop/backend/asgi.py new file mode 100644 index 0000000..a7a5338 --- /dev/null +++ b/desktop/backend/asgi.py @@ -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()) diff --git a/desktop/backend/djarea_clients.py b/desktop/backend/djarea_clients.py new file mode 100644 index 0000000..acb1b2c --- /dev/null +++ b/desktop/backend/djarea_clients.py @@ -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") diff --git a/desktop/backend/models.py b/desktop/backend/models.py new file mode 100644 index 0000000..ea0d5ce --- /dev/null +++ b/desktop/backend/models.py @@ -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 diff --git a/desktop/backend/settings.py b/desktop/backend/settings.py new file mode 100644 index 0000000..bd01eca --- /dev/null +++ b/desktop/backend/settings.py @@ -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 diff --git a/desktop/backend/urls.py b/desktop/backend/urls.py new file mode 100644 index 0000000..ad66c10 --- /dev/null +++ b/desktop/backend/urls.py @@ -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"^(?Passets/.+)$", serve_dist), + path("favicon.ico", serve_dist, {"path": "favicon.ico"}), + path("", serve_dist), +] diff --git a/desktop/frontend/index.html b/desktop/frontend/index.html new file mode 100644 index 0000000..5b30e6d --- /dev/null +++ b/desktop/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + Djarea Desktop + + + +
+ + + diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json new file mode 100644 index 0000000..33aca79 --- /dev/null +++ b/desktop/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "djarea-desktop-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5173", + "build": "vite build" + }, + "dependencies": { + "@rythazhur/djarea": "file:../../react", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "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" + } +} diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx new file mode 100644 index 0000000..91fc51e --- /dev/null +++ b/desktop/frontend/src/App.tsx @@ -0,0 +1,215 @@ +import { useState, useEffect, useCallback } from 'react' +import { DjareaProvider, useDjarea, useDjareaStatus } from '@rythazhur/djarea' + +// ─── System Info ──────────────────────────────────────────────────────────── + +function SystemInfo() { + const { call } = useDjarea() + const [info, setInfo] = useState | null>(null) + + useEffect(() => { + call('system_info').then(setInfo).catch(() => {}) + }, [call]) + + if (!info) return
Loading system info...
+ + return ( +
+

System

+ + + {Object.entries(info).map(([k, v]) => ( + + + + + ))} + +
{k}{String(v)}
+
+ ) +} + +// ─── Connection Status ────────────────────────────────────────────────────── + +function StatusBar() { + const status = useDjareaStatus() + return ( +
+ {status} +
+ ) +} + +// ─── Notes ────────────────────────────────────────────────────────────────── + +type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string } + +function Notes() { + const { call } = useDjarea() + const [notes, setNotes] = useState([]) + const [selected, setSelected] = useState(null) + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + + const refresh = useCallback(() => { + call<{ notes: Note[] }>('list_notes').then(d => setNotes(d.notes)).catch(() => {}) + }, [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 }) + 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 select = (n: Note) => { + setSelected(n) + setTitle(n.title) + setContent(n.content) + } + + return ( +
+

Notes ({notes.length})

+
+
+ {notes.map(n => ( +
select(n)} + style={{ + ...styles.noteItem, + borderLeft: selected?.id === n.id ? '3px solid #6cf' : '3px solid transparent', + }} + > + {n.pinned ? '\u{1f4cc} ' : ''}{n.title} + +
+ ))} + {notes.length === 0 &&
No notes yet
} +
+
+ setTitle(e.target.value)} + placeholder="Title" + style={styles.input} + /> +