diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a80ead1b..90a67551a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,12 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Set up Chrome + uses: browser-actions/setup-chrome@v1 + + - name: Set up ChromeDriver + uses: nanasess/setup-chromedriver@v2 + - name: Install dependencies run: | python -m pip install --upgrade pip wheel setuptools diff --git a/channels/testing/live.py b/channels/testing/live.py index ce5ff1097..7522ccbf5 100644 --- a/channels/testing/live.py +++ b/channels/testing/live.py @@ -39,36 +39,44 @@ def live_server_url(self): def live_server_ws_url(self): return "ws://%s:%s" % (self.host, self._port) - def _pre_setup(self): + @classmethod + def setUpClass(cls): for connection in connections.all(): - if self._is_in_memory_db(connection): + if cls._is_in_memory_db(connection): raise ImproperlyConfigured( "ChannelLiveServerTestCase can not be used with in memory databases" ) - super(ChannelsLiveServerTestCase, self)._pre_setup() + super().setUpClass() - self._live_server_modified_settings = modify_settings( - ALLOWED_HOSTS={"append": self.host} + cls._live_server_modified_settings = modify_settings( + ALLOWED_HOSTS={"append": cls.host} ) - self._live_server_modified_settings.enable() + cls._live_server_modified_settings.enable() get_application = partial( make_application, - static_wrapper=self.static_wrapper if self.serve_static else None, + static_wrapper=cls.static_wrapper if cls.serve_static else None, ) - self._server_process = self.ProtocolServerProcess(self.host, get_application) - self._server_process.start() - self._server_process.ready.wait() - self._port = self._server_process.port.value + cls._server_process = cls.ProtocolServerProcess(cls.host, get_application) + cls._server_process.start() + while True: + if not cls._server_process.ready.wait(timeout=1): + if cls._server_process.is_alive(): + continue + raise RuntimeError("Server stopped") from None + break + cls._port = cls._server_process.port.value - def _post_teardown(self): - self._server_process.terminate() - self._server_process.join() - self._live_server_modified_settings.disable() - super(ChannelsLiveServerTestCase, self)._post_teardown() + @classmethod + def tearDownClass(cls): + cls._server_process.terminate() + cls._server_process.join() + cls._live_server_modified_settings.disable() + super().tearDownClass() - def _is_in_memory_db(self, connection): + @classmethod + def _is_in_memory_db(cls, connection): """ Check if DatabaseWrapper holds in memory database. """ diff --git a/setup.cfg b/setup.cfg index bc60b699c..507335f7b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,6 +44,7 @@ tests = pytest pytest-django pytest-asyncio + selenium daphne = daphne>=4.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index 94c9803a7..a13df45a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,26 +1,12 @@ +import os + import pytest from django.conf import settings def pytest_configure(): - settings.configure( - DATABASES={ - "default": { - "ENGINE": "django.db.backends.sqlite3", - # Override Django’s default behaviour of using an in-memory database - # in tests for SQLite, since that avoids connection.close() working. - "TEST": {"NAME": "test_db.sqlite3"}, - } - }, - INSTALLED_APPS=[ - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.admin", - "channels", - ], - SECRET_KEY="Not_a_secret_key", - ) + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.sample_project.config.settings" + settings._setup() def pytest_generate_tests(metafunc): diff --git a/tests/sample_project/__init__.py b/tests/sample_project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sample_project/config/__init__.py b/tests/sample_project/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sample_project/config/asgi.py b/tests/sample_project/config/asgi.py new file mode 100644 index 000000000..eeaf98d9f --- /dev/null +++ b/tests/sample_project/config/asgi.py @@ -0,0 +1,35 @@ +""" +ASGI config for sample_project project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +from django.core.asgi import get_asgi_application +from django.urls import path + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator +from tests.sample_project.sampleapp.consumers import LiveMessageConsumer + +application = ProtocolTypeRouter( + { + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack( + URLRouter( + [ + path( + "ws/message/", + LiveMessageConsumer.as_asgi(), + name="live_message_counter", + ), + ] + ) + ) + ), + "http": get_asgi_application(), + } +) diff --git a/tests/sample_project/config/settings.py b/tests/sample_project/config/settings.py new file mode 100644 index 000000000..610572173 --- /dev/null +++ b/tests/sample_project/config/settings.py @@ -0,0 +1,99 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = "Not_a_secret_key" + +DEBUG = True + +ALLOWED_HOSTS = [] + +INSTALLED_APPS = [ + "daphne", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "tests.sample_project.sampleapp", + "channels", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "tests.sample_project.config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.csrf", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "tests.sample_project.config.wsgi.application" +ASGI_APPLICATION = "tests.sample_project.config.asgi.application" + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + }, +} + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "sampleapp/sampleapp.sqlite3", + # Override Django’s default behaviour of using an in-memory database + # in tests for SQLite, since that avoids connection.close() working. + "TEST": {"NAME": "test_db.sqlite3"}, + } +} + + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": ( + "django.contrib.auth.password_validation." + "UserAttributeSimilarityValidator" + ), + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + +STATIC_URL = "static/" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/sample_project/config/urls.py b/tests/sample_project/config/urls.py new file mode 100644 index 000000000..23b2f5003 --- /dev/null +++ b/tests/sample_project/config/urls.py @@ -0,0 +1,31 @@ +""" +URL configuration for sample_project project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.conf import settings +from django.contrib import admin +from django.urls import path +from django.views.generic import RedirectView + +urlpatterns = [ + path("admin/", admin.site.urls), + path( + "favicon.ico", + RedirectView.as_view( + url=settings.STATIC_URL + "sampleapp/images/django.svg", permanent=True + ), + ), +] diff --git a/tests/sample_project/config/wsgi.py b/tests/sample_project/config/wsgi.py new file mode 100644 index 000000000..88ea52eba --- /dev/null +++ b/tests/sample_project/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for sample_project project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/tests/sample_project/manage.py b/tests/sample_project/manage.py new file mode 100755 index 000000000..d28672eae --- /dev/null +++ b/tests/sample_project/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/sample_project/sampleapp/__init__.py b/tests/sample_project/sampleapp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sample_project/sampleapp/admin.py b/tests/sample_project/sampleapp/admin.py new file mode 100644 index 000000000..22ce87e90 --- /dev/null +++ b/tests/sample_project/sampleapp/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from .models import Message + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ("title", "created") + change_list_template = "admin/sampleapp/message/change_list.html" diff --git a/tests/sample_project/sampleapp/apps.py b/tests/sample_project/sampleapp/apps.py new file mode 100644 index 000000000..1c2a786ad --- /dev/null +++ b/tests/sample_project/sampleapp/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SampleappConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tests.sample_project.sampleapp" diff --git a/tests/sample_project/sampleapp/consumers.py b/tests/sample_project/sampleapp/consumers.py new file mode 100644 index 000000000..b163581ce --- /dev/null +++ b/tests/sample_project/sampleapp/consumers.py @@ -0,0 +1,59 @@ +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from .models import Message + + +class LiveMessageConsumer(AsyncJsonWebsocketConsumer): + async def connect(self): + await self.channel_layer.group_add("live_message", self.channel_name) + await self.accept() + await self.send_current_state() + + async def disconnect(self, close_code): + await self.channel_layer.group_discard("live_message", self.channel_name) + + @database_sync_to_async + def _fetch_state(self): + qs = Message.objects.order_by("-created") + return { + "count": qs.count(), + "messages": list(qs.values("id", "title", "message")), + } + + @database_sync_to_async + def _create_message(self, title, text): + Message.objects.create(title=title, message=text) + + @database_sync_to_async + def _delete_message(self, msg_id): + Message.objects.filter(id=msg_id).delete() + + async def receive_json(self, content): + action = content.get("action", "create") + + if action == "create": + title = content.get("title", "") + text = content.get("message", "") + await self._create_message(title=title, text=text) + + elif action == "delete": + msg_id = content.get("id") + await self._delete_message(msg_id) + + # After any action, rebroadcast current state + await self.send_current_state() + + async def send_current_state(self): + state = await self._fetch_state() + await self.channel_layer.group_send( + "live_message", {"type": "broadcast_message", **state} + ) + + async def broadcast_message(self, event): + await self.send_json( + { + "count": event["count"], + "messages": event["messages"], + } + ) diff --git a/tests/sample_project/sampleapp/migrations/0001_initial.py b/tests/sample_project/sampleapp/migrations/0001_initial.py new file mode 100644 index 000000000..f2e8cc224 --- /dev/null +++ b/tests/sample_project/sampleapp/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2 on 2025-05-25 11:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Message", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("message", models.TextField()), + ("created", models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/tests/sample_project/sampleapp/migrations/__init__.py b/tests/sample_project/sampleapp/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sample_project/sampleapp/models.py b/tests/sample_project/sampleapp/models.py new file mode 100644 index 000000000..cc4274f35 --- /dev/null +++ b/tests/sample_project/sampleapp/models.py @@ -0,0 +1,10 @@ +from django.db import models + + +class Message(models.Model): + title = models.CharField(max_length=255) + message = models.TextField() + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.title diff --git a/tests/sample_project/sampleapp/static/sampleapp/css/styles.css b/tests/sample_project/sampleapp/static/sampleapp/css/styles.css new file mode 100644 index 000000000..e3b14d512 --- /dev/null +++ b/tests/sample_project/sampleapp/static/sampleapp/css/styles.css @@ -0,0 +1,97 @@ +.container { + margin: 20px 0; + padding: 15px; + border: 1px solid var(--border-color); + box-shadow: 0 2px 4px rgba(169, 168, 168, 0.1); + background: var(--body-bg); + color: var(--body-fg); + border-radius: 15px; +} + +#heading { + color: var(--heading-fg); + margin-bottom: 20px; + font-size: 1.2em; +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 10px; +} + +#msgTitle, +#msgTextArea { + padding: 8px; + background: var(--input-bg); + color: var(--input-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 1em; + font-family: inherit; +} + +#sendBtn { + padding: 6px 12px; + background: var(--button-bg); + color: var(--button-fg); + border: 1px solid var(--border-color); + cursor: pointer; + border-radius: 4px; + align-self: flex-start; +} +#sendBtn:hover { + background: var(--button-hover-bg); +} + +.stats { + margin-top: 15px; + margin-bottom: 10px; + font-size: 0.9em; +} + +#cardsContainer { + display: flex; + gap: 10px; +} + +.messageCard { + position: relative; + height: max-content; + background: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 2px 6px rgba(0,0,0,0.1); + padding: 10px 12px 12px; + overflow: hidden; + display: flex; + flex-direction: column; + max-width: 25%; +} + +.messageCard h3 { + margin: 0 0 6px; + font-size: 1.1em; + font-weight: bold; + color: var(--body-fg); + padding-right: 20px; +} + +.messageCard p { + margin: 0; + font-size: 0.95em; + color: var(--body-fg); + overflow-wrap: break-word; +} + +.messageCard #deleteBtn { + margin-top: 7px; + background: transparent; + padding: 0; + color: red; + border: none; + cursor: pointer; + line-height: 1; + align-self: flex-end; +} diff --git a/tests/sample_project/sampleapp/static/sampleapp/images/django.svg b/tests/sample_project/sampleapp/static/sampleapp/images/django.svg new file mode 100644 index 000000000..b3e95f0ca --- /dev/null +++ b/tests/sample_project/sampleapp/static/sampleapp/images/django.svg @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/tests/sample_project/sampleapp/static/sampleapp/js/scripts.js b/tests/sample_project/sampleapp/static/sampleapp/js/scripts.js new file mode 100644 index 000000000..435553d0d --- /dev/null +++ b/tests/sample_project/sampleapp/static/sampleapp/js/scripts.js @@ -0,0 +1,75 @@ +(function() { + const countElement = document.getElementById('messageCount'); + const container = document.getElementById('cardsContainer'); + const titleInput = document.getElementById('msgTitle'); + const textInput = document.getElementById('msgTextArea'); + const sendBtn = document.getElementById('sendBtn'); + + const ws = initWebSocket(); + + function initWebSocket() { + const wsPath = `ws://${window.location.host}/ws/message/`; + const socket = new WebSocket(wsPath); + + window.websocketConnected = false; + + socket.onopen = () => { + window.websocketConnected = true; + console.log('WebSocket connected'); + }; + socket.onerror = err => console.error('WebSocket Error:', err); + socket.onclose = () => console.warn('WebSocket closed'); + socket.onmessage = handleMessage; + + return socket; + } + + function handleMessage(e) { + const data = JSON.parse(e.data); + renderState(data.count, data.messages); + } + + function renderState(count, messages) { + countElement.textContent = count; + container.innerHTML = ''; + messages.forEach(msg => container.appendChild(createCard(msg))); + } + + function createCard({ id, title, message }) { + const card = document.createElement('div'); + card.className = 'messageCard'; + + const h3 = document.createElement('h3'); + h3.textContent = title; + + card.appendChild(h3); + + const p = document.createElement('p'); + p.textContent = message; + card.appendChild(p); + + const deleteBtn = document.createElement('button'); + deleteBtn.id = 'deleteBtn'; + deleteBtn.textContent = 'Delete'; + deleteBtn.onclick = () => sendAction('delete', { id }); + card.appendChild(deleteBtn); + + return card; + } + + function sendAction(action, data = {}) { + const payload = { action, ...data }; + ws.send(JSON.stringify(payload)); + } + + sendBtn.onclick = () => { + const title = titleInput.value.trim(); + const message = textInput.value.trim(); + if (!title || !message) { + return alert('Please enter both title and message.'); + } + sendAction('create', { title, message }); + titleInput.value = ''; + textInput.value = ''; +}; +})(); diff --git a/tests/sample_project/sampleapp/templates/admin/sampleapp/message/change_list.html b/tests/sample_project/sampleapp/templates/admin/sampleapp/message/change_list.html new file mode 100644 index 000000000..f2ceb8760 --- /dev/null +++ b/tests/sample_project/sampleapp/templates/admin/sampleapp/message/change_list.html @@ -0,0 +1,33 @@ +{% extends "admin/change_list.html" %} +{% load static %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block content %} + {{ block.super }} + +