diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f26793e..17080b52 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,67 +12,66 @@ name: "CodeQL" on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - # Runs at 22:21 on Monday. - - cron: '21 22 * * 1' + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + # Runs at 22:21 on Monday. + - cron: "21 22 * * 1" jobs: - analyze: - name: Analyze - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - actions: read - contents: read - security-events: write + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + strategy: + fail-fast: false + matrix: + language: ["javascript", "python"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - steps: - - name: Checkout repository - uses: actions/checkout@v3 + steps: + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 09871fa6..b79d3cd2 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -8,10 +8,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 9b07a206..72a04dae 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -11,9 +11,9 @@ jobs: release-package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install dependencies diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index 6fc32336..a98e9869 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -8,10 +8,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 2c38c905..907e1a2c 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -14,10 +14,10 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - name: Check docs build diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index c7de8acc..c450cf9f 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -17,9 +17,9 @@ jobs: matrix: python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a63334..de10fc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Using the following categories, list your changes in this order: ### Added - Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook. +- `django_css` and `django_js` components now support de-duplication via the `prevent_duplicates=...` parameter. - More robust control over ReactPy clean up tasks! - `settings.py:REACTPY_CLEAN_INTERVAL` to control how often ReactPy automatically performs cleaning tasks. - `settings.py:REACTPY_CLEAN_SESSIONS` to control whether ReactPy automatically cleans up expired sessions. diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 5ceb2fe6..9b510921 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -369,7 +369,7 @@ This is often used to create chat systems, synchronize data between components, pip install channels-redis ``` - 3. Configure your `settings.py` to use `RedisChannelLayer` as your layer backend. + 3. Configure your `settings.py` to use `#!python RedisChannelLayer` as your layer backend. ```python linenums="0" CHANNEL_LAYERS = { diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index d81f522a..a8903040 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -12,6 +12,8 @@ Django template tags can be used within your HTML templates to provide ReactPy f This template tag can be used to insert any number of ReactPy components onto your page. +Each component loaded via this template tag will receive a dedicated WebSocket connection to the server. + === "my-template.html" {% include-markdown "../../../README.md" start="" end="" %} @@ -27,7 +29,7 @@ This template tag can be used to insert any number of ReactPy components onto yo | `#!python class` | `#!python str | None` | The HTML class to apply to the top-level component div. | `#!python None` | | `#!python key` | `#!python Any` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `#!python key` within a template tag is effectively useless. | `#!python None` | | `#!python host` | `#!python str | None` | The host to use for the ReactPy connections. If unset, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `#!python None` | - | `#!python prerender` | `#!python str` | If `#!python "true"`, the component will pre-rendered, which enables SEO compatibility and reduces perceived latency. | `#!python "false"` | + | `#!python prerender` | `#!python str` | If `#!python "true"` the component will pre-rendered, which enables SEO compatibility and reduces perceived latency. | `#!python "false"` | | `#!python offline` | `#!python str` | The dotted path to a component that will be displayed if your root component loses connection to the server. Keep in mind, this `offline` component will be non-interactive (hooks won't operate). | `#!python ""` | | `#!python **kwargs` | `#!python Any` | The keyword arguments to provide to the component. | N/A | diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 75b0c321..43bad660 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,21 +1,26 @@ from __future__ import annotations import json -import os from typing import Any, Callable, Sequence, Union, cast, overload from urllib.parse import urlencode +from uuid import uuid4 from warnings import warn -from django.contrib.staticfiles.finders import find -from django.core.cache import caches from django.http import HttpRequest from django.urls import reverse from django.views import View from reactpy import component, hooks, html, utils +from reactpy.core.types import VdomDictConstructor from reactpy.types import Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError -from reactpy_django.utils import generate_obj_name, import_module, render_view +from reactpy_django.hooks import use_scope +from reactpy_django.utils import ( + cached_static_contents, + generate_obj_name, + import_module, + render_view, +) # Type hints for: @@ -27,8 +32,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Any: - ... +) -> Any: ... # Type hints for: @@ -39,8 +43,7 @@ def view_to_component( compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Callable[[Callable], Any]: - ... +) -> Callable[[Callable], Any]: ... def view_to_component( @@ -122,30 +125,50 @@ def constructor( return constructor -def django_css(static_path: str, key: Key | None = None): +def django_css( + static_path: str, prevent_duplicates: bool = True, key: Key | None = None +): """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: static_path: The path to the static file. This path is identical to what you would \ use on Django's `{% static %}` template tag + prevent_duplicates: If True, this component will only load the file if no other \ + component (in your connection's component tree) has already loaded it. key: A key to uniquely identify this component which is unique amongst a component's \ immediate siblings """ - return _django_css(static_path=static_path, key=key) + return _django_static_file( + static_path=static_path, + prevent_duplicates=prevent_duplicates, + file_type="css", + vdom_constructor=html.style, + key=key, + ) -def django_js(static_path: str, key: Key | None = None): +def django_js( + static_path: str, prevent_duplicates: bool = True, key: Key | None = None +): """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: static_path: The path to the static file. This path is identical to what you would \ use on Django's `{% static %}` template tag. + prevent_duplicates: If True, this component will only load the file if no other \ + component (in your connection's component tree) has already loaded it. key: A key to uniquely identify this component which is unique amongst a component's \ immediate siblings """ - return _django_js(static_path=static_path, key=key) + return _django_static_file( + static_path=static_path, + prevent_duplicates=prevent_duplicates, + file_type="js", + vdom_constructor=html.script, + key=key, + ) @component @@ -250,37 +273,47 @@ def _view_to_iframe( @component -def _django_css(static_path: str): - return html.style(_cached_static_contents(static_path)) - - -@component -def _django_js(static_path: str): - return html.script(_cached_static_contents(static_path)) - +def _django_static_file( + static_path: str, + prevent_duplicates: bool, + file_type: str, + vdom_constructor: VdomDictConstructor, +): + scope = use_scope() + mount_trigger, set_mount_trigger = hooks.use_state(True) + ownership_uuid = hooks.use_memo(lambda: uuid4()) + + # Configure the ASGI scope to track the file + if prevent_duplicates: + scope.setdefault("reactpy", {}).setdefault(file_type, {}) + scope["reactpy"][file_type].setdefault(static_path, ownership_uuid) + + # Check if other _django_static_file components have unmounted + @hooks.use_effect(dependencies=None) + async def mount_manager(): + if prevent_duplicates and not scope["reactpy"][file_type].get(static_path): + print("new mount host: ", ownership_uuid) + scope["reactpy"][file_type].setdefault(static_path, ownership_uuid) + set_mount_trigger(not mount_trigger) + + # Notify other components that we've unmounted + @hooks.use_effect(dependencies=[]) + async def unmount_manager(): + # FIXME: This is not working as expected. Dismount is not being called when the component is removed from a list. + if not prevent_duplicates: + return + print("registering new unmount func") -def _cached_static_contents(static_path: str) -> str: - from reactpy_django.config import REACTPY_CACHE + def unmount(): + print("unmount func called") + if scope["reactpy"][file_type].get(static_path) == ownership_uuid: + print("unmounting") + scope["reactpy"][file_type].pop(static_path) - # Try to find the file within Django's static files - abs_path = find(static_path) - if not abs_path: - raise FileNotFoundError( - f"Could not find static file {static_path} within Django's static files." - ) + return unmount - # Fetch the file from cache, if available - last_modified_time = os.stat(abs_path).st_mtime - cache_key = f"reactpy_django:static_contents:{static_path}" - file_contents: str | None = caches[REACTPY_CACHE].get( - cache_key, version=int(last_modified_time) - ) - if file_contents is None: - with open(abs_path, encoding="utf-8") as static_file: - file_contents = static_file.read() - caches[REACTPY_CACHE].delete(cache_key) - caches[REACTPY_CACHE].set( - cache_key, file_contents, timeout=None, version=int(last_modified_time) - ) - - return file_contents + # Render the component, if needed + if not prevent_duplicates or ( + scope["reactpy"][file_type].get(static_path) == ownership_uuid + ): + return vdom_constructor(cached_static_contents(static_path)) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 3ed0e2de..9ecffa7e 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -12,6 +12,8 @@ from asgiref.sync import async_to_sync from channels.db import database_sync_to_async +from django.contrib.staticfiles.finders import find +from django.core.cache import caches from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects from django.db.models.base import Model from django.db.models.query import QuerySet @@ -366,3 +368,30 @@ def render(self): def get_pk(model): """Returns the value of the primary key for a Django model.""" return getattr(model, model._meta.pk.name) + + +def cached_static_contents(static_path: str) -> str: + from reactpy_django.config import REACTPY_CACHE + + # Try to find the file within Django's static files + abs_path = find(static_path) + if not abs_path: + raise FileNotFoundError( + f"Could not find static file {static_path} within Django's static files." + ) + + # Fetch the file from cache, if available + last_modified_time = os.stat(abs_path).st_mtime + cache_key = f"reactpy_django:static_contents:{static_path}" + file_contents: str | None = caches[REACTPY_CACHE].get( + cache_key, version=int(last_modified_time) + ) + if file_contents is None: + with open(abs_path, encoding="utf-8") as static_file: + file_contents = static_file.read() + caches[REACTPY_CACHE].delete(cache_key) + caches[REACTPY_CACHE].set( + cache_key, file_contents, timeout=None, version=int(last_modified_time) + ) + + return file_contents diff --git a/tests/test_app/components.py b/tests/test_app/components.py index dbe9bd8f..f355a0fe 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,6 +1,7 @@ import asyncio import inspect from pathlib import Path +from uuid import uuid4 import reactpy_django from channels.auth import login, logout @@ -135,6 +136,63 @@ def django_css(): ) +@component +def django_css_prevent_duplicates(): + scope = reactpy_django.hooks.use_scope() + components = hooks.use_ref( + [ + reactpy_django.components.django_css( + "django-css-prevent-duplicates-test.css", key=str(uuid4()) + ) + ] + ) + uuid, set_uuid = hooks.use_state(uuid4()) + + async def add_end_css(event): + components.current.append( + reactpy_django.components.django_css( + "django-css-prevent-duplicates-test.css", key=str(uuid4()) + ) + ) + set_uuid(uuid4()) + + async def add_front_css(event): + components.current.insert( + 0, + reactpy_django.components.django_css( + "django-css-prevent-duplicates-test.css", key=str(uuid4()) + ), + ) + set_uuid(uuid4()) + + async def remove_end_css(event): + if components.current: + components.current.pop() + set_uuid(uuid4()) + + async def remove_front_css(event): + if components.current: + components.current.pop(0) + set_uuid(uuid4()) + + return html.div( + {"id": "django-css-prevent-duplicates"}, + html.div({"style": {"display": "inline"}}, "django_css_prevent_duplicates: "), + html.button("This text should be blue."), + html.div( + html.button({"on_click": add_end_css}, "Add End File"), + html.button({"on_click": add_front_css}, "Add Front File"), + html.button({"on_click": remove_end_css}, "Remove End File"), + html.button({"on_click": remove_front_css}, "Remove Front File"), + ), + html.div( + f'CSS ownership tracked via ASGI scope: {scope.get("reactpy",{}).get("css")}' + ), + html.div(f"Components with CSS: {components.current}"), + components.current, + ) + + @component def django_js(): success = False @@ -147,6 +205,65 @@ def django_js(): ) +@component +def django_js_prevent_duplicates(): + scope = reactpy_django.hooks.use_scope() + components = hooks.use_ref( + [ + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", key=str(uuid4()) + ) + ] + ) + uuid, set_uuid = hooks.use_state(uuid4()) + + async def add_end_js(event): + components.current.append( + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", key=str(uuid4()) + ) + ) + set_uuid(uuid4()) + + async def add_front_js(event): + components.current.insert( + 0, + reactpy_django.components.django_js( + "django-js-prevent-duplicates-test.js", key=str(uuid4()) + ), + ) + set_uuid(uuid4()) + + async def remove_end_js(event): + if components.current: + components.current.pop() + set_uuid(uuid4()) + + async def remove_front_js(event): + if components.current: + components.current.pop(0) + set_uuid(uuid4()) + + return html.div( + {"id": "django-js-prevent-duplicates"}, + html.div( + "django_js_prevent_duplicates: ", + html.div({"id": "django-js-prevent-duplicates-value"}), + ), + html.div( + html.button({"on_click": add_end_js}, "Add End File"), + html.button({"on_click": add_front_js}, "Add Front File"), + html.button({"on_click": remove_end_js}, "Remove End File"), + html.button({"on_click": remove_front_js}, "Remove Front File"), + ), + html.div( + f'JS ownership tracked via ASGI scope: {scope.get("reactpy",{}).get("js")}' + ), + html.div(f"Components with JS: {components.current}"), + components.current, + ) + + @component @reactpy_django.decorators.auth_required( fallback=html.div( @@ -720,9 +837,9 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": "AnonymousUser" - if current_user.is_anonymous - else current_user.username, + "data-username": ( + "AnonymousUser" if current_user.is_anonymous else current_user.username + ), }, html.div("use_user_data"), html.button({"class": "login-1", "on_click": login_user1}, "Login 1"), @@ -788,9 +905,9 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": "AnonymousUser" - if current_user.is_anonymous - else current_user.username, + "data-username": ( + "AnonymousUser" if current_user.is_anonymous else current_user.username + ), }, html.div("use_user_data_with_default"), html.button({"class": "login-3", "on_click": login_user3}, "Login 3"), diff --git a/tests/test_app/static/django-css-prevent-duplicates-test.css b/tests/test_app/static/django-css-prevent-duplicates-test.css new file mode 100644 index 00000000..59d2446b --- /dev/null +++ b/tests/test_app/static/django-css-prevent-duplicates-test.css @@ -0,0 +1,3 @@ +#django-css-prevent-duplicates button { + color: rgb(0, 0, 255); +} diff --git a/tests/test_app/static/django-js-prevent-duplicates-test.js b/tests/test_app/static/django-js-prevent-duplicates-test.js new file mode 100644 index 00000000..c52328ff --- /dev/null +++ b/tests/test_app/static/django-js-prevent-duplicates-test.js @@ -0,0 +1,17 @@ +// This file uses ReactPy's layout that can convert a JavaScript file into ReactJS `useEffect` hook +() => { + // this is run once the script is loaded and each time its content changes + let el = document.body.querySelector("#django-js-prevent-duplicates-value"); + if (el.dataset.django_js === undefined) { + el.dataset.django_js = 0; + } + el.dataset.django_js = Number(el.dataset.django_js) + 1; + el.textContent = + "Currently loaded by " + el.dataset.django_js + " component(s)"; + return () => { + // this is run when the script is unloaded (i.e. it's removed from the tree) or just before its content changes + el.dataset.django_js = Number(el.dataset.django_js) - 1; + el.textContent = + "Currently loaded by " + el.dataset.django_js + " component(s)"; + }; +}; diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index f15094ac..f686836b 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -41,6 +41,10 @@

ReactPy Test Page


{% component "test_app.components.django_js" %}
+ {% component "test_app.components.django_css_prevent_duplicates" %} +
+ {% component "test_app.components.django_js_prevent_duplicates" %} +
{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %} diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 241e5659..816e04e6 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -135,7 +135,7 @@ def test_use_location(self): def test_use_origin(self): self.page.locator("#use-origin[data-success=true]").wait_for() - def test_static_css(self): + def test_django_css(self): self.assertEqual( self.page.wait_for_selector("#django-css button").evaluate( "e => window.getComputedStyle(e).getPropertyValue('color')" @@ -143,9 +143,13 @@ def test_static_css(self): "rgb(0, 0, 255)", ) - def test_static_js(self): + def test_django_css_prevent_duplicates(self): ... + + def test_django_js(self): self.page.locator("#django-js[data-success=true]").wait_for() + def test_django_js_prevent_duplicates(self): ... + def test_unauthorized_user(self): self.assertRaises( TimeoutError,