From f5f231cdf256d115961a7ba96d102e1c1f6e0e9c Mon Sep 17 00:00:00 2001 From: dee077 Date: Tue, 20 May 2025 02:58:47 +0530 Subject: [PATCH 1/8] Make ChannelsLiveServerTestCase compatible with Django 5.2 (#2148) --- channels/testing/live.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/channels/testing/live.py b/channels/testing/live.py index ce5ff1097..c09e34992 100644 --- a/channels/testing/live.py +++ b/channels/testing/live.py @@ -39,28 +39,29 @@ 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 _pre_setup(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(ChannelsLiveServerTestCase, cls)._pre_setup() - 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() + cls._server_process.ready.wait() + cls._port = cls._server_process.port.value def _post_teardown(self): self._server_process.terminate() @@ -68,7 +69,8 @@ def _post_teardown(self): self._live_server_modified_settings.disable() super(ChannelsLiveServerTestCase, self)._post_teardown() - def _is_in_memory_db(self, connection): + @staticmethod + def _is_in_memory_db(connection): """ Check if DatabaseWrapper holds in memory database. """ From 613ecaf88be61c8a37e961bb61de98f8c59467e7 Mon Sep 17 00:00:00 2001 From: dee077 Date: Tue, 20 May 2025 23:19:48 +0530 Subject: [PATCH 2/8] Add backward compatiblity --- channels/testing/live.py | 78 ++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/channels/testing/live.py b/channels/testing/live.py index c09e34992..261de75b8 100644 --- a/channels/testing/live.py +++ b/channels/testing/live.py @@ -1,6 +1,7 @@ from functools import partial from daphne.testing import DaphneProcess +from django import VERSION from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler from django.core.exceptions import ImproperlyConfigured from django.db import connections @@ -39,29 +40,60 @@ def live_server_url(self): def live_server_ws_url(self): return "ws://%s:%s" % (self.host, self._port) - @classmethod - def _pre_setup(cls): - for connection in connections.all(): - if cls._is_in_memory_db(connection): - raise ImproperlyConfigured( - "ChannelLiveServerTestCase can not be used with in memory databases" - ) - - super(ChannelsLiveServerTestCase, cls)._pre_setup() - - cls._live_server_modified_settings = modify_settings( - ALLOWED_HOSTS={"append": cls.host} - ) - cls._live_server_modified_settings.enable() - - get_application = partial( - make_application, - static_wrapper=cls.static_wrapper if cls.serve_static else None, - ) - cls._server_process = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process.start() - cls._server_process.ready.wait() - cls._port = cls._server_process.port.value + if VERSION >= (5, 2): + + @classmethod + def _pre_setup(cls): + for connection in connections.all(): + if cls._is_in_memory_db(connection): + raise ImproperlyConfigured( + "ChannelLiveServerTestCase can not be used with in memory " + "databases" + ) + + super(ChannelsLiveServerTestCase, cls)._pre_setup() + + cls._live_server_modified_settings = modify_settings( + ALLOWED_HOSTS={"append": cls.host} + ) + cls._live_server_modified_settings.enable() + + get_application = partial( + make_application, + static_wrapper=cls.static_wrapper if cls.serve_static else None, + ) + cls._server_process = cls.ProtocolServerProcess(cls.host, get_application) + cls._server_process.start() + cls._server_process.ready.wait() + cls._port = cls._server_process.port.value + + else: + + def _pre_setup(self): + for connection in connections.all(): + if self._is_in_memory_db(connection): + raise ImproperlyConfigured( + "ChannelLiveServerTestCase can not be used with in memory " + "databases" + ) + + super(ChannelsLiveServerTestCase, self)._pre_setup() + + self._live_server_modified_settings = modify_settings( + ALLOWED_HOSTS={"append": self.host} + ) + self._live_server_modified_settings.enable() + + get_application = partial( + make_application, + static_wrapper=self.static_wrapper if self.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 def _post_teardown(self): self._server_process.terminate() From 7c90195bc5a571631f82082d15c455c2da8efbc9 Mon Sep 17 00:00:00 2001 From: dee077 Date: Wed, 21 May 2025 15:07:28 +0530 Subject: [PATCH 3/8] Cleaner fix insted of redundant if else logic --- channels/testing/live.py | 84 ++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 54 deletions(-) diff --git a/channels/testing/live.py b/channels/testing/live.py index 261de75b8..f13d727e3 100644 --- a/channels/testing/live.py +++ b/channels/testing/live.py @@ -40,60 +40,28 @@ def live_server_url(self): def live_server_ws_url(self): return "ws://%s:%s" % (self.host, self._port) - if VERSION >= (5, 2): - - @classmethod - def _pre_setup(cls): - for connection in connections.all(): - if cls._is_in_memory_db(connection): - raise ImproperlyConfigured( - "ChannelLiveServerTestCase can not be used with in memory " - "databases" - ) - - super(ChannelsLiveServerTestCase, cls)._pre_setup() - - cls._live_server_modified_settings = modify_settings( - ALLOWED_HOSTS={"append": cls.host} - ) - cls._live_server_modified_settings.enable() - - get_application = partial( - make_application, - static_wrapper=cls.static_wrapper if cls.serve_static else None, - ) - cls._server_process = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process.start() - cls._server_process.ready.wait() - cls._port = cls._server_process.port.value - - else: - - def _pre_setup(self): - for connection in connections.all(): - if self._is_in_memory_db(connection): - raise ImproperlyConfigured( - "ChannelLiveServerTestCase can not be used with in memory " - "databases" - ) - - super(ChannelsLiveServerTestCase, self)._pre_setup() - - self._live_server_modified_settings = modify_settings( - ALLOWED_HOSTS={"append": self.host} - ) - self._live_server_modified_settings.enable() - - get_application = partial( - make_application, - static_wrapper=self.static_wrapper if self.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 + def _pre_setup(self): + for connection in connections.all(): + if self._is_in_memory_db(connection): + raise ImproperlyConfigured( + "ChannelLiveServerTestCase can not be used with in memory databases" + ) + + super(ChannelsLiveServerTestCase, self)._pre_setup() + + self._live_server_modified_settings = modify_settings( + ALLOWED_HOSTS={"append": self.host} + ) + self._live_server_modified_settings.enable() + + get_application = partial( + make_application, + static_wrapper=self.static_wrapper if self.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 def _post_teardown(self): self._server_process.terminate() @@ -108,3 +76,11 @@ def _is_in_memory_db(connection): """ if connection.vendor == "sqlite": return connection.is_in_memory_db() + + +# Workaround for Django 5.2: _pre_setup became a classmethod. +# TODO: Remove this workaround once support for Django <5.2 is dropped. +if VERSION >= (5, 2): + ChannelsLiveServerTestCase._pre_setup = classmethod( + ChannelsLiveServerTestCase._pre_setup + ) From adf1ba270ff6b430cfa1627315ce0ef00782d607 Mon Sep 17 00:00:00 2001 From: dee077 Date: Thu, 29 May 2025 12:57:44 +0530 Subject: [PATCH 4/8] Add sample project but pytest fails --- tests/conftest.py | 27 ++++ tests/sample_project/__init__.py | 0 tests/sample_project/config/__init__.py | 0 tests/sample_project/config/asgi.py | 35 ++++ tests/sample_project/config/settings.py | 138 ++++++++++++++++ tests/sample_project/config/urls.py | 31 ++++ tests/sample_project/config/wsgi.py | 16 ++ tests/sample_project/manage.py | 22 +++ tests/sample_project/sampleapp/__init__.py | 0 tests/sample_project/sampleapp/admin.py | 9 ++ tests/sample_project/sampleapp/apps.py | 6 + tests/sample_project/sampleapp/consumers.py | 66 ++++++++ .../sampleapp/migrations/0001_initial.py | 30 ++++ .../sampleapp/migrations/__init__.py | 0 tests/sample_project/sampleapp/models.py | 10 ++ .../sampleapp/static/sampleapp/css/styles.css | 97 +++++++++++ .../static/sampleapp/images/django.svg | 10 ++ .../sampleapp/static/sampleapp/js/scripts.js | 75 +++++++++ .../admin/sampleapp/message/change_list.html | 33 ++++ tests/sample_project/tests/__init__.py | 0 tests/sample_project/tests/selenium_mixin.py | 153 ++++++++++++++++++ tests/sample_project/tests/test_selenium.py | 134 +++++++++++++++ 22 files changed, 892 insertions(+) create mode 100644 tests/sample_project/__init__.py create mode 100644 tests/sample_project/config/__init__.py create mode 100644 tests/sample_project/config/asgi.py create mode 100644 tests/sample_project/config/settings.py create mode 100644 tests/sample_project/config/urls.py create mode 100644 tests/sample_project/config/wsgi.py create mode 100755 tests/sample_project/manage.py create mode 100644 tests/sample_project/sampleapp/__init__.py create mode 100644 tests/sample_project/sampleapp/admin.py create mode 100644 tests/sample_project/sampleapp/apps.py create mode 100644 tests/sample_project/sampleapp/consumers.py create mode 100644 tests/sample_project/sampleapp/migrations/0001_initial.py create mode 100644 tests/sample_project/sampleapp/migrations/__init__.py create mode 100644 tests/sample_project/sampleapp/models.py create mode 100644 tests/sample_project/sampleapp/static/sampleapp/css/styles.css create mode 100644 tests/sample_project/sampleapp/static/sampleapp/images/django.svg create mode 100644 tests/sample_project/sampleapp/static/sampleapp/js/scripts.js create mode 100644 tests/sample_project/sampleapp/templates/admin/sampleapp/message/change_list.html create mode 100644 tests/sample_project/tests/__init__.py create mode 100644 tests/sample_project/tests/selenium_mixin.py create mode 100644 tests/sample_project/tests/test_selenium.py diff --git a/tests/conftest.py b/tests/conftest.py index 94c9803a7..e3c9c14b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,12 +13,39 @@ def pytest_configure(): } }, INSTALLED_APPS=[ + "daphne", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.admin", + "django.contrib.staticfiles", + "tests.sample_project.sampleapp", "channels", ], + STATIC_URL="static/", + ASGI_APPLICATION="tests.sample_project.config.asgi.application", + MIDDLEWARE=[ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + ], + ROOT_URLCONF="tests.sample_project.config.urls", + CHANNEL_LAYERS={ + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + }, + }, + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + ], + }, + }, + ], SECRET_KEY="Not_a_secret_key", ) 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..17a17cbc3 --- /dev/null +++ b/tests/sample_project/config/settings.py @@ -0,0 +1,138 @@ +""" +Django settings for sample_project project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-w%kkd-b39(9uvz68x!-9+xt=&9q&^j@#uc_&=g%-s@bcsz*qbu" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "daphne", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "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 = "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 = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + }, +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "sampleapp/sampleapp.sqlite3", + "TEST": { + "NAME": BASE_DIR / "sampleapp/test_sampleapp.sqlite3", + }, + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +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", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +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..1957dcb19 --- /dev/null +++ b/tests/sample_project/sampleapp/consumers.py @@ -0,0 +1,66 @@ +import traceback + +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): + try: + 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() + + except Exception as err: + tb = traceback.format_exc() + print(f"Error in LiveMessageConsumer: {err}\n{tb}") + + 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..e5f9a35c2 --- /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; +} \ No newline at end of file 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..6ce0270d7 --- /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 }} + +
+

Live Messages

+
+ + + + +
+ + +
+ Total messages: 0 +
+ +
+
+{% endblock %} + +{% block footer %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/tests/sample_project/tests/__init__.py b/tests/sample_project/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sample_project/tests/selenium_mixin.py b/tests/sample_project/tests/selenium_mixin.py new file mode 100644 index 000000000..27de6bfcf --- /dev/null +++ b/tests/sample_project/tests/selenium_mixin.py @@ -0,0 +1,153 @@ +from django.contrib.auth import get_user_model +from selenium import webdriver +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + + +class SeleniumMixin: + """ + Mixin to provide a headless Chromium browser for + end-to-end tests to test ChannelsLiveServerTestCase. + """ + + admin_username = "admin" + admin_password = "password" + email = "admin@email.com" + + @classmethod + def get_chrome_webdriver(cls): + options = webdriver.ChromeOptions() + # options.add_argument("--headless") + options.page_load_strategy = "eager" + return webdriver.Chrome(options=options) + + def _create_admin(self, username, password, email): + User = get_user_model() + return User.objects.create_superuser( + username=username, password=password, email=email + ) + + def setUp(self): + super().setUp() + self.admin = self._create_admin( + username=self.admin_username, password=self.admin_password, email=self.email + ) + self.web_driver = self.get_chrome_webdriver() + + def open(self, url, html_container="#content-main", driver=None, timeout=5): + """Opens a URL. + + Input Arguments: + + - url: URL to open + - driver: selenium driver (default: cls.base_driver). + - html_container: CSS selector of an HTML element to look for once + the page is ready + - timeout: timeout until the page is ready + """ + driver = self.web_driver + driver.get(f"{self.live_server_url}{url}") + self._wait_until_page_ready(driver=driver, html_container=html_container) + + def _wait_until_page_ready( + self, html_container="#content-main", timeout=5, driver=None + ): + driver = self.web_driver + WebDriverWait(driver, timeout).until( + lambda d: d.execute_script("return document.readyState") == "complete" + ) + self.wait_for_visibility(By.CSS_SELECTOR, html_container, timeout, driver) + + def get_browser_logs(self, driver=None): + driver = self.web_driver + return driver.get_log("browser") + + def login(self, username=None, password=None, driver=None): + """Log in to the admin dashboard. + + Input Arguments: + + - username: username to be used for login (default: + cls.admin_username) + - password: password to be used for login (default: + cls.admin_password) + - driver: selenium driver (default: cls.web_driver). + """ + driver = self.web_driver + if not username: + username = self.admin_username + if not password: + password = self.admin_password + driver.get(f"{self.live_server_url}/admin/login/") + self._wait_until_page_ready(driver=driver) + if "admin/login" in driver.current_url: + driver.find_element(by=By.NAME, value="username").send_keys(username) + driver.find_element(by=By.NAME, value="password").send_keys(password) + driver.find_element(by=By.XPATH, value='//input[@type="submit"]').click() + self._wait_until_page_ready(driver=driver) + + def logout(self, driver=None): + driver = self.web_driver + driver.find_element(By.CSS_SELECTOR, ".account-button").click() + driver.find_element(By.CSS_SELECTOR, "#logout-form button").click() + + def find_element(self, by, value, timeout=2, driver=None, wait_for="visibility"): + driver = self.web_driver + method = f"wait_for_{wait_for}" + getattr(self, method)(by, value, timeout) + return driver.find_element(by=by, value=value) + + def find_elements(self, by, value, timeout=2, driver=None, wait_for="visibility"): + driver = self.web_driver + method = f"wait_for_{wait_for}" + getattr(self, method)(by, value, timeout) + return driver.find_elements(by=by, value=value) + + def wait_for_visibility(self, by, value, timeout=2, driver=None): + driver = self.web_driver + return self.wait_for( + "visibility_of_element_located", by, value, timeout, driver + ) + + def wait_for(self, method, by, value, timeout=2, driver=None): + driver = self.web_driver + try: + return WebDriverWait(driver, timeout).until( + getattr(EC, method)(((by, value))) + ) + except TimeoutException as e: + print(self.get_browser_logs(driver)) + self.fail(f'{method} of "{value}" failed: {e}') + + def wait_for_websocket_connection( + self, substring="WebSocket connected", timeout=50 + ): + """ + Wait until window.websocketConnected is true, or fail after timeout. + """ + try: + WebDriverWait(self.web_driver, timeout).until( + lambda d: d.execute_script("return window.websocketConnected === true") + ) + except TimeoutException: + logs = self.get_browser_logs() + print("\n Browser logs on WS-flag timeout:") + for entry in logs: + print(f"[{entry['level']}] {entry['message']}") + self.fail( + f"Timed out waiting for window.websocketConnected after {timeout}s" + ) + + def tearDown(self): + logs = self.web_driver.get_log("browser") + severe_logs = [entry for entry in logs if entry.get("level") == "SEVERE"] + + if severe_logs: + print("\n----Browser console SEVERE logs----") + for entry in severe_logs: + msg = entry.get("message") + print(f"[SEVERE] {msg}") + self.web_driver.quit() + super().tearDown() diff --git a/tests/sample_project/tests/test_selenium.py b/tests/sample_project/tests/test_selenium.py new file mode 100644 index 000000000..7ddb1e674 --- /dev/null +++ b/tests/sample_project/tests/test_selenium.py @@ -0,0 +1,134 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait + +from channels.testing import ChannelsLiveServerTestCase +from tests.sample_project.sampleapp.models import Message + +from .selenium_mixin import SeleniumMixin + + +class TestSampleApp(SeleniumMixin, ChannelsLiveServerTestCase): + serve_static = True + + def setUp(self): + super().setUp() + self.login() + self.open("/admin/sampleapp/message/") + self.wait_for_websocket_connection() + + def _create_message(self, title="Test Title", message="Test Message"): + return Message.objects.create(title=title, message=message) + + def _wait_for_exact_text(self, by, locator, exact, timeout=2): + WebDriverWait(self.web_driver, timeout).until( + lambda driver: driver.find_element(by, locator).text == str(exact) + ) + + def test_sampleapp_display(self): + heading = self.find_element(By.ID, "heading") + titleInput = self.find_element(By.ID, "msgTitle") + messageInput = self.find_element(By.ID, "msgTextArea") + addMessageButton = self.find_element(By.ID, "sendBtn") + + self.assertTrue(heading.is_displayed(), "Heading should be visible") + self.assertTrue(titleInput.is_displayed(), "Title input should be visible") + self.assertTrue(messageInput.is_displayed(), "Message input should be visible") + self.assertTrue( + addMessageButton.is_displayed(), "Send button should be visible" + ) + + def test_send_empty_title_and_message(self): + addMessageButton = self.find_element(By.ID, "sendBtn") + self.assertIsNotNone(addMessageButton, "Send button should be present") + addMessageButton.click() + + alert = self.web_driver.switch_to.alert + self.assertEqual(alert.text, "Please enter both title and message.") + alert.accept() + + def test_create_message(self): + self._wait_for_exact_text(By.ID, "messageCount", 0) + titleInput = self.find_element(By.ID, "msgTitle") + self.assertIsNotNone(titleInput, "Title input should be present") + messageInput = self.find_element(By.ID, "msgTextArea") + self.assertIsNotNone(messageInput, "Message input should be present") + addMessageButton = self.find_element(By.ID, "sendBtn") + self.assertIsNotNone(addMessageButton, "Send button should be present") + titleInput.send_keys("Test Title") + messageInput.send_keys("Test Message") + addMessageButton.click() + + self._wait_for_exact_text(By.ID, "messageCount", 1) + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "1") + + def test_delete_message(self): + self._create_message() + self.web_driver.refresh() + + self._wait_for_exact_text(By.ID, "messageCount", 1) + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "1") + + deleteButton = self.find_element(By.ID, "deleteBtn") + self.assertIsNotNone(deleteButton, "Delete button should be present") + deleteButton.click() + + self._wait_for_exact_text(By.ID, "messageCount", 0) + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "0") + + def test_real_time_create_message(self): + self.web_driver.switch_to.new_window("tab") + tabs = self.web_driver.window_handles + self.web_driver.switch_to.window(tabs[1]) + + self.open("/admin/sampleapp/message/") + titleInput = self.find_element(By.ID, "msgTitle") + self.assertIsNotNone(titleInput, "Title input should be present") + messageInput = self.find_element(By.ID, "msgTextArea") + self.assertIsNotNone(messageInput, "Message input should be present") + addMessageButton = self.find_element(By.ID, "sendBtn") + self.assertIsNotNone(addMessageButton, "Send button should be present") + titleInput.send_keys("Test Title") + messageInput.send_keys("Test Message") + addMessageButton.click() + self._wait_for_exact_text(By.ID, "messageCount", 1) + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "1") + + self.web_driver.switch_to.window(tabs[0]) + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "1") + + def test_real_time_delete_message(self): + self._create_message() + self.web_driver.refresh() + + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "1") + + self.web_driver.switch_to.new_window("tab") + tabs = self.web_driver.window_handles + self.web_driver.switch_to.window(tabs[1]) + + self.open("/admin/sampleapp/message/") + deleteButton = self.find_element(By.ID, "deleteBtn") + self.assertIsNotNone(deleteButton, "Delete button should be present") + deleteButton.click() + self._wait_for_exact_text(By.ID, "messageCount", 0) + + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "0") + + self.web_driver.switch_to.window(tabs[0]) + messageCount = self.find_element(By.ID, "messageCount") + self.assertIsNotNone(messageCount, "Message count should be present") + self.assertEqual(messageCount.text, "0") From 7d3315dfdfe2dcc9292e6a72b053b72982989251 Mon Sep 17 00:00:00 2001 From: dee077 Date: Fri, 30 May 2025 20:02:56 +0530 Subject: [PATCH 5/8] Fix failing test suit and add chormedriver in ci --- .github/workflows/tests.yml | 6 +++ setup.cfg | 1 + tests/sample_project/sampleapp/consumers.py | 27 ++++------ tests/sample_project/tests/selenium_mixin.py | 6 +-- tests/sample_project/tests/test_selenium.py | 57 -------------------- tests/test_generic_websocket.py | 16 +++--- tests/test_testing.py | 10 ++-- 7 files changed, 32 insertions(+), 91 deletions(-) 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/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/sample_project/sampleapp/consumers.py b/tests/sample_project/sampleapp/consumers.py index 1957dcb19..b163581ce 100644 --- a/tests/sample_project/sampleapp/consumers.py +++ b/tests/sample_project/sampleapp/consumers.py @@ -1,5 +1,3 @@ -import traceback - from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer @@ -32,24 +30,19 @@ def _delete_message(self, msg_id): Message.objects.filter(id=msg_id).delete() async def receive_json(self, content): - try: - action = content.get("action", "create") - - if action == "create": - title = content.get("title", "") - text = content.get("message", "") - await self._create_message(title=title, text=text) + action = content.get("action", "create") - elif action == "delete": - msg_id = content.get("id") - await self._delete_message(msg_id) + if action == "create": + title = content.get("title", "") + text = content.get("message", "") + await self._create_message(title=title, text=text) - # After any action, rebroadcast current state - await self.send_current_state() + elif action == "delete": + msg_id = content.get("id") + await self._delete_message(msg_id) - except Exception as err: - tb = traceback.format_exc() - print(f"Error in LiveMessageConsumer: {err}\n{tb}") + # After any action, rebroadcast current state + await self.send_current_state() async def send_current_state(self): state = await self._fetch_state() diff --git a/tests/sample_project/tests/selenium_mixin.py b/tests/sample_project/tests/selenium_mixin.py index 27de6bfcf..239f63268 100644 --- a/tests/sample_project/tests/selenium_mixin.py +++ b/tests/sample_project/tests/selenium_mixin.py @@ -19,7 +19,7 @@ class SeleniumMixin: @classmethod def get_chrome_webdriver(cls): options = webdriver.ChromeOptions() - # options.add_argument("--headless") + options.add_argument("--headless") options.page_load_strategy = "eager" return webdriver.Chrome(options=options) @@ -121,9 +121,7 @@ def wait_for(self, method, by, value, timeout=2, driver=None): print(self.get_browser_logs(driver)) self.fail(f'{method} of "{value}" failed: {e}') - def wait_for_websocket_connection( - self, substring="WebSocket connected", timeout=50 - ): + def wait_for_websocket_connection(self, substring="WebSocket connected", timeout=5): """ Wait until window.websocketConnected is true, or fail after timeout. """ diff --git a/tests/sample_project/tests/test_selenium.py b/tests/sample_project/tests/test_selenium.py index 7ddb1e674..0f875a1e2 100644 --- a/tests/sample_project/tests/test_selenium.py +++ b/tests/sample_project/tests/test_selenium.py @@ -24,63 +24,6 @@ def _wait_for_exact_text(self, by, locator, exact, timeout=2): lambda driver: driver.find_element(by, locator).text == str(exact) ) - def test_sampleapp_display(self): - heading = self.find_element(By.ID, "heading") - titleInput = self.find_element(By.ID, "msgTitle") - messageInput = self.find_element(By.ID, "msgTextArea") - addMessageButton = self.find_element(By.ID, "sendBtn") - - self.assertTrue(heading.is_displayed(), "Heading should be visible") - self.assertTrue(titleInput.is_displayed(), "Title input should be visible") - self.assertTrue(messageInput.is_displayed(), "Message input should be visible") - self.assertTrue( - addMessageButton.is_displayed(), "Send button should be visible" - ) - - def test_send_empty_title_and_message(self): - addMessageButton = self.find_element(By.ID, "sendBtn") - self.assertIsNotNone(addMessageButton, "Send button should be present") - addMessageButton.click() - - alert = self.web_driver.switch_to.alert - self.assertEqual(alert.text, "Please enter both title and message.") - alert.accept() - - def test_create_message(self): - self._wait_for_exact_text(By.ID, "messageCount", 0) - titleInput = self.find_element(By.ID, "msgTitle") - self.assertIsNotNone(titleInput, "Title input should be present") - messageInput = self.find_element(By.ID, "msgTextArea") - self.assertIsNotNone(messageInput, "Message input should be present") - addMessageButton = self.find_element(By.ID, "sendBtn") - self.assertIsNotNone(addMessageButton, "Send button should be present") - titleInput.send_keys("Test Title") - messageInput.send_keys("Test Message") - addMessageButton.click() - - self._wait_for_exact_text(By.ID, "messageCount", 1) - messageCount = self.find_element(By.ID, "messageCount") - self.assertIsNotNone(messageCount, "Message count should be present") - self.assertEqual(messageCount.text, "1") - - def test_delete_message(self): - self._create_message() - self.web_driver.refresh() - - self._wait_for_exact_text(By.ID, "messageCount", 1) - messageCount = self.find_element(By.ID, "messageCount") - self.assertIsNotNone(messageCount, "Message count should be present") - self.assertEqual(messageCount.text, "1") - - deleteButton = self.find_element(By.ID, "deleteBtn") - self.assertIsNotNone(deleteButton, "Delete button should be present") - deleteButton.click() - - self._wait_for_exact_text(By.ID, "messageCount", 0) - messageCount = self.find_element(By.ID, "messageCount") - self.assertIsNotNone(messageCount, "Message count should be present") - self.assertEqual(messageCount.text, "0") - def test_real_time_create_message(self): self.web_driver.switch_to.new_window("tab") tabs = self.web_driver.window_handles diff --git a/tests/test_generic_websocket.py b/tests/test_generic_websocket.py index 0ade1a022..ddfa0e6db 100644 --- a/tests/test_generic_websocket.py +++ b/tests/test_generic_websocket.py @@ -12,7 +12,7 @@ from channels.testing import WebsocketCommunicator -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_websocket_consumer(): """ @@ -54,7 +54,7 @@ def disconnect(self, code): assert "disconnected" in results -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_multiple_websocket_consumers_with_sessions(): """ @@ -95,7 +95,7 @@ def receive(self, text_data=None, bytes_data=None): await second_communicator.disconnect() -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_websocket_consumer_subprotocol(): """ @@ -118,7 +118,7 @@ def connect(self): assert subprotocol == "subprotocol2" -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_websocket_consumer_groups(): """ @@ -294,7 +294,7 @@ async def receive(self, text_data=None, bytes_data=None): await communicator.disconnect() -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_json_websocket_consumer(): """ @@ -434,7 +434,7 @@ async def _my_private_handler(self, _): @pytest.mark.parametrize("async_consumer", [False, True]) -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_accept_headers(async_consumer): """ @@ -459,7 +459,7 @@ async def connect(self): @pytest.mark.parametrize("async_consumer", [False, True]) -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_close_reason(async_consumer): """ @@ -487,7 +487,7 @@ async def connect(self): assert msg["reason"] == "test reason" -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_websocket_receive_with_none_text(): """ diff --git a/tests/test_testing.py b/tests/test_testing.py index fbfbf436f..43a147ae1 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -74,7 +74,7 @@ def connect(self): self.send(text_data=self.scope["url_route"]["kwargs"]["message"]) -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_websocket_communicator(): """ @@ -101,7 +101,7 @@ async def test_websocket_communicator(): await communicator.disconnect() -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_websocket_incorrect_read_json(): """ @@ -120,7 +120,7 @@ async def test_websocket_incorrect_read_json(): ) -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_websocket_application(): """ @@ -138,7 +138,7 @@ async def test_websocket_application(): await communicator.disconnect() -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio async def test_timeout_disconnect(): """ @@ -183,7 +183,7 @@ def connect(self): ] -@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio @pytest.mark.parametrize("path", paths) async def test_connection_scope(path): From 5dc781658f49af599e36f5bf641f1afcee389138 Mon Sep 17 00:00:00 2001 From: dee077 Date: Sat, 7 Jun 2025 21:37:57 +0530 Subject: [PATCH 6/8] Minor space fixes --- tests/sample_project/sampleapp/static/sampleapp/css/styles.css | 2 +- .../templates/admin/sampleapp/message/change_list.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sample_project/sampleapp/static/sampleapp/css/styles.css b/tests/sample_project/sampleapp/static/sampleapp/css/styles.css index e5f9a35c2..e3b14d512 100644 --- a/tests/sample_project/sampleapp/static/sampleapp/css/styles.css +++ b/tests/sample_project/sampleapp/static/sampleapp/css/styles.css @@ -94,4 +94,4 @@ cursor: pointer; line-height: 1; align-self: flex-end; -} \ No newline at end of file +} 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 index 6ce0270d7..f2ceb8760 100644 --- a/tests/sample_project/sampleapp/templates/admin/sampleapp/message/change_list.html +++ b/tests/sample_project/sampleapp/templates/admin/sampleapp/message/change_list.html @@ -13,7 +13,7 @@

Live Messages

- +
From 1814ed0f08e9a7f24df9aea51118283519ba930a Mon Sep 17 00:00:00 2001 From: dee077 Date: Sat, 7 Jun 2025 21:40:43 +0530 Subject: [PATCH 7/8] Add attribution to OpenWISP for SeleniumMixin --- tests/sample_project/tests/selenium_mixin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/sample_project/tests/selenium_mixin.py b/tests/sample_project/tests/selenium_mixin.py index 239f63268..8b8f6327e 100644 --- a/tests/sample_project/tests/selenium_mixin.py +++ b/tests/sample_project/tests/selenium_mixin.py @@ -10,6 +10,9 @@ class SeleniumMixin: """ Mixin to provide a headless Chromium browser for end-to-end tests to test ChannelsLiveServerTestCase. + + Adapted from OpenWISP's SeleniumTestMixin in openwisp_utils. + https://github.com/openwisp/openwisp-utils/blob/master/openwisp_utils/tests/selenium.py """ admin_username = "admin" From c1fa2ff15d0aa8c48f2ad5fa2132a695f4e3553d Mon Sep 17 00:00:00 2001 From: dee077 Date: Fri, 13 Jun 2025 03:23:00 +0530 Subject: [PATCH 8/8] Used fix for ChannelsLiveServerTestCase from #2164 and cleaned conftest.py --- channels/testing/live.py | 56 ++++++++++++------------- tests/conftest.py | 49 ++-------------------- tests/sample_project/config/settings.py | 55 ++++-------------------- 3 files changed, 39 insertions(+), 121 deletions(-) diff --git a/channels/testing/live.py b/channels/testing/live.py index f13d727e3..7522ccbf5 100644 --- a/channels/testing/live.py +++ b/channels/testing/live.py @@ -1,7 +1,6 @@ from functools import partial from daphne.testing import DaphneProcess -from django import VERSION from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler from django.core.exceptions import ImproperlyConfigured from django.db import connections @@ -40,47 +39,46 @@ 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 - - def _post_teardown(self): - self._server_process.terminate() - self._server_process.join() - self._live_server_modified_settings.disable() - super(ChannelsLiveServerTestCase, self)._post_teardown() - - @staticmethod - def _is_in_memory_db(connection): + 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 + + @classmethod + def tearDownClass(cls): + cls._server_process.terminate() + cls._server_process.join() + cls._live_server_modified_settings.disable() + super().tearDownClass() + + @classmethod + def _is_in_memory_db(cls, connection): """ Check if DatabaseWrapper holds in memory database. """ if connection.vendor == "sqlite": return connection.is_in_memory_db() - - -# Workaround for Django 5.2: _pre_setup became a classmethod. -# TODO: Remove this workaround once support for Django <5.2 is dropped. -if VERSION >= (5, 2): - ChannelsLiveServerTestCase._pre_setup = classmethod( - ChannelsLiveServerTestCase._pre_setup - ) diff --git a/tests/conftest.py b/tests/conftest.py index e3c9c14b7..a13df45a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,53 +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=[ - "daphne", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.admin", - "django.contrib.staticfiles", - "tests.sample_project.sampleapp", - "channels", - ], - STATIC_URL="static/", - ASGI_APPLICATION="tests.sample_project.config.asgi.application", - MIDDLEWARE=[ - "django.contrib.sessions.middleware.SessionMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - ], - ROOT_URLCONF="tests.sample_project.config.urls", - CHANNEL_LAYERS={ - "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer", - }, - }, - TEMPLATES=[ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - ], - }, - }, - ], - 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/config/settings.py b/tests/sample_project/config/settings.py index 17a17cbc3..610572173 100644 --- a/tests/sample_project/config/settings.py +++ b/tests/sample_project/config/settings.py @@ -1,35 +1,13 @@ -""" -Django settings for sample_project project. - -Generated by 'django-admin startproject' using Django 5.2. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.2/ref/settings/ -""" - from pathlib import Path -# Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +SECRET_KEY = "Not_a_secret_key" -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-w%kkd-b39(9uvz68x!-9+xt=&9q&^j@#uc_&=g%-s@bcsz*qbu" - -# SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [] - -# Application definition - INSTALLED_APPS = [ "daphne", "django.contrib.admin", @@ -38,7 +16,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "sampleapp", + "tests.sample_project.sampleapp", "channels", ] @@ -52,7 +30,7 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "config.urls" +ROOT_URLCONF = "tests.sample_project.config.urls" TEMPLATES = [ { @@ -70,8 +48,8 @@ }, ] -WSGI_APPLICATION = "config.wsgi.application" -ASGI_APPLICATION = "config.asgi.application" +WSGI_APPLICATION = "tests.sample_project.config.wsgi.application" +ASGI_APPLICATION = "tests.sample_project.config.asgi.application" CHANNEL_LAYERS = { "default": { @@ -79,23 +57,17 @@ }, } -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "sampleapp/sampleapp.sqlite3", - "TEST": { - "NAME": BASE_DIR / "sampleapp/test_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"}, } } -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { "NAME": ( @@ -114,10 +86,6 @@ }, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" @@ -126,13 +94,6 @@ USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - STATIC_URL = "static/" -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field - DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"