diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 91db302f32..88019e882b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -87,6 +87,34 @@ jobs:
- name: Check that "evaluation" section appears in help string
run: python -m evap --help | grep --fixed-strings "[evaluation]"
+ test_frontend:
+ name: Test Frontend
+
+ runs-on: ubuntu-22.04
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - uses: ./.github/setup_evap
+ with:
+ shell: .#evap-frontend-dev
+ start-db: true
+
+ - name: Compile assets
+ run: |
+ python manage.py ts compile
+ python manage.py scss
+
+ - name: Run tests (shuffled)
+ run: coverage run manage.py test --shuffle --tag selenium
+ - name: Upload coverage
+ uses: codecov/codecov-action@v5
+ with:
+ flags: frontend-tests
+ token: ${{ secrets.CODECOV_TOKEN }}
+
mypy:
runs-on: ubuntu-22.04
@@ -98,6 +126,21 @@ jobs:
- name: Run MyPy
run: mypy
+ typescript:
+ runs-on: ubuntu-22.04
+ name: Test Typescript
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ - uses: ./.github/setup_evap
+ with:
+ npm-ci: true
+
+ - name: Run tests
+ run: python manage.py ts test
+
linter:
runs-on: ubuntu-22.04
@@ -171,83 +214,6 @@ jobs:
- name: Reload backup
run: echo "yy" | deployment/load_production_backup.sh backup.json
- compile_scss:
- runs-on: ubuntu-22.04
-
- name: Compile Scss
-
- steps:
- - uses: actions/checkout@v4
- with:
- submodules: true
- - uses: ./.github/setup_evap
- with:
- npm-ci: true
-
- - name: Compile Scss
- run: npx sass evap/static/scss/evap.scss evap/static/css/evap.css
- - name: Store Css
- uses: actions/upload-artifact@v4
- with:
- name: css
- path: evap/static/css/evap.css
-
- render_pages:
- runs-on: ubuntu-22.04
-
- name: Render Html pages
-
- steps:
- - uses: actions/checkout@v4
- - uses: ./.github/setup_evap
- with:
- start-db: true
-
- - name: Render pages
- run: coverage run manage.py ts render_pages
- - name: Upload coverage
- uses: codecov/codecov-action@v5
- with:
- flags: render-pages
- token: ${{ secrets.CODECOV_TOKEN }}
- - name: Store rendered pages
- uses: actions/upload-artifact@v4
- with:
- name: rendered-pages
- path: evap/static/ts/rendered
-
- typescript:
- runs-on: ubuntu-22.04
-
- needs: [ compile_scss, render_pages ]
-
- name: Test Typescript
-
- steps:
- - uses: actions/checkout@v4
- with:
- submodules: true
- - uses: ./.github/setup_evap
- with:
- npm-ci: true
-
- - run: npx puppeteer browsers install chrome
-
- - name: Compile Typescript
- run: npx tsc --project evap/static/ts/tsconfig.compile.json
- - name: Load rendered pages
- uses: actions/download-artifact@v4
- with:
- name: rendered-pages
- path: evap/static/ts/rendered
- - name: Load Css
- uses: actions/download-artifact@v4
- with:
- name: css
- path: evap/static/css
- - name: Run tests
- run: xvfb-run --auto-servernum npx jest
-
macos-nix-build:
runs-on: macos-14
name: Build nix environment on MacOS
diff --git a/deployment/manage_autocompletion.sh b/deployment/manage_autocompletion.sh
index 29e52d6908..30571721ae 100755
--- a/deployment/manage_autocompletion.sh
+++ b/deployment/manage_autocompletion.sh
@@ -3,7 +3,7 @@
# generated using
# ./manage.py | grep -v -E "^\[|^$" | tail -n +3 | sort | xargs
COMMANDS="admin_generator anonymize changepassword check clean_pyc clear_cache clearsessions collectstatic compile_pyc compilemessages create_command create_jobs create_template_tags createcachetable createsuperuser dbshell delete_squashed_migrations describe_form diffsettings drop_test_database dump_testdata dumpdata dumpscript export_emails find_template findstatic flush format generate_password generate_secret_key graph_models inspectdb lint list_model_info list_signals loaddata loaddata_unlogged mail_debug makemessages makemigrations managestate merge_model_instances migrate notes optimizemigration pipchecker precommit print_settings print_user_for_session raise_test_exception refresh_results_cache reload_testdata remove_stale_contenttypes reset_db reset_schema run runjob runjobs runprofileserver runscript runserver runserver_plus scss send_reminders sendtestemail set_default_site set_fake_emails set_fake_passwords shell shell_plus show_template_tags show_urls showmigrations sqlcreate sqldiff sqldsn sqlflush sqlmigrate sqlsequencereset squashmigrations startapp startproject sync_s3 syncdata test testserver tools translate ts typecheck unreferenced_files update_evaluation_states update_permissions validate_templates"
-TS_COMMANDS="compile test render_pages"
+TS_COMMANDS="compile test"
_managepy_complete()
{
diff --git a/evap/contributor/tests/test_views.py b/evap/contributor/tests/test_views.py
index 7eeb1ae243..29e772db1f 100644
--- a/evap/contributor/tests/test_views.py
+++ b/evap/contributor/tests/test_views.py
@@ -8,7 +8,6 @@
WebTest,
WebTestWith200Check,
create_evaluation_with_responsible_and_editor,
- render_pages,
submit_with_modal,
)
@@ -136,8 +135,6 @@ def test_without_questionnaires_assigned(self):
class TestContributorEvaluationEditView(WebTest):
- render_pages_url = "/contributor/evaluation/PK/edit"
-
@classmethod
def setUpTestData(cls):
result = create_evaluation_with_responsible_and_editor()
@@ -146,23 +143,6 @@ def setUpTestData(cls):
cls.evaluation = result["evaluation"]
cls.url = f"/contributor/evaluation/{cls.evaluation.pk}/edit"
- @render_pages
- def render_pages(self):
- self.evaluation.allow_editors_to_edit = False
- self.evaluation.save()
-
- content_without_allow_editors_to_edit = self.app.get(self.url, user=self.editor).content
-
- self.evaluation.allow_editors_to_edit = True
- self.evaluation.save()
-
- content_with_allow_editors_to_edit = self.app.get(self.url, user=self.editor).content
-
- return {
- "normal": content_without_allow_editors_to_edit,
- "allow_editors_to_edit": content_with_allow_editors_to_edit,
- }
-
def test_not_authenticated(self):
"""
Asserts that an unauthorized user gets redirected to the login page.
diff --git a/evap/evaluation/management/commands/ts.py b/evap/evaluation/management/commands/ts.py
index 7a4725e027..ab0da07def 100644
--- a/evap/evaluation/management/commands/ts.py
+++ b/evap/evaluation/management/commands/ts.py
@@ -1,22 +1,9 @@
import argparse
import subprocess # nosec
-import unittest
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
-from django.test.runner import DiscoverRunner
-
-
-class RenderPagesRunner(DiscoverRunner):
- """Test runner which only includes `render_pages.*` methods.
- The actual logic of the page rendering is implemented in the `@render_pages` decorator."""
-
- test_loader = unittest.TestLoader()
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.test_loader.testMethodPrefix = "render_pages"
class Command(BaseCommand):
@@ -31,7 +18,6 @@ def add_arguments(self, parser: argparse.ArgumentParser):
self.add_fresh_argument(compile_parser)
test_parser = subparsers.add_parser("test")
self.add_fresh_argument(test_parser)
- subparsers.add_parser("render_pages")
@staticmethod
def add_fresh_argument(parser: argparse.ArgumentParser):
@@ -47,8 +33,6 @@ def handle(self, *args, **options):
self.compile(**options)
elif options["command"] == "test":
self.test(**options)
- elif options["command"] == "render_pages":
- self.render_pages(**options)
def run_command(self, command):
try:
@@ -80,14 +64,4 @@ def compile(self, watch=False, fresh=False, **_options):
def test(self, **options):
call_command("scss")
self.compile(**options)
- self.render_pages()
self.run_command(["npx", "jest"])
-
- @staticmethod
- def render_pages(**_options):
- # Enable debug mode as otherwise a collectstatic beforehand would be necessary,
- # as missing static files would result into an error.
- test_runner = RenderPagesRunner(debug_mode=True)
- failed_tests = test_runner.run_tests([])
- if failed_tests > 0:
- raise CommandError("Failures during render_pages")
diff --git a/evap/evaluation/tests/test_commands.py b/evap/evaluation/tests/test_commands.py
index 20cc6f03e6..da6ab0928f 100644
--- a/evap/evaluation/tests/test_commands.py
+++ b/evap/evaluation/tests/test_commands.py
@@ -269,12 +269,10 @@ def test_ts_compile_with_watch(self, mock_subprocess_run):
@patch("subprocess.run")
@patch("evap.evaluation.management.commands.ts.call_command")
- @patch("evap.evaluation.management.commands.ts.Command.render_pages")
- def test_ts_test(self, mock_render_pages, mock_call_command, mock_subprocess_run):
+ def test_ts_test(self, mock_call_command, mock_subprocess_run):
management.call_command("ts", "test")
# Mock render pages to prevent a second call into the test framework
- mock_render_pages.assert_called_once()
mock_call_command.assert_called_once_with("scss")
mock_subprocess_run.assert_has_calls(
[
diff --git a/evap/evaluation/tests/test_live.py b/evap/evaluation/tests/test_live.py
new file mode 100644
index 0000000000..107d2915e6
--- /dev/null
+++ b/evap/evaluation/tests/test_live.py
@@ -0,0 +1,25 @@
+from django.core import mail
+from django.urls import reverse
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.expected_conditions import text_to_be_present_in_element, visibility_of_element_located
+
+from evap.evaluation.tests.tools import LiveServerTest
+
+
+class ContactModalTests(LiveServerTest):
+ def test_contact_modal(self) -> None:
+ self.selenium.get(self.live_server_url + reverse("evaluation:index"))
+ self.selenium.find_element(By.ID, "feedbackModalShowButton").click()
+ self.wait.until(visibility_of_element_located((By.ID, "feedbackModalMessageText")))
+ self.selenium.find_element(By.ID, "feedbackModalMessageText").send_keys("Test message")
+ self.selenium.find_element(By.ID, "feedbackModalActionButton").click()
+
+ self.wait.until(
+ text_to_be_present_in_element(
+ (By.CSS_SELECTOR, "#successMessageModal_feedbackModal .modal-body"),
+ "Your message was successfully sent.",
+ )
+ )
+ self.assertEqual(len(mail.outbox), 1)
+
+ self.assertEqual(mail.outbox[0].subject, f"[EvaP] Message from {self.manager.email}")
diff --git a/evap/evaluation/tests/test_views.py b/evap/evaluation/tests/test_views.py
index a44266c11b..238a0c7709 100644
--- a/evap/evaluation/tests/test_views.py
+++ b/evap/evaluation/tests/test_views.py
@@ -11,20 +11,10 @@
WebTestWith200Check,
create_evaluation_with_responsible_and_editor,
make_manager,
- store_ts_test_asset,
)
from evap.staff.tests.utils import WebTestStaffMode
-class RenderJsTranslationCatalog(WebTest):
- url = reverse("javascript-catalog")
-
- def render_pages(self):
- # Not using render_pages decorator to manually create a single (special) javascript file
- content = self.app.get(self.url).content
- store_ts_test_asset("catalog.js", content)
-
-
@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"])
class TestIndexView(WebTest):
url = "/"
diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py
index 495e81a05b..b77a84dea2 100644
--- a/evap/evaluation/tests/tools.py
+++ b/evap/evaluation/tests/tools.py
@@ -1,19 +1,26 @@
-import functools
-import os
-from collections.abc import Sequence
+import time
+from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from datetime import timedelta
+from importlib import import_module
+from typing import Any
import django.test
import django_webtest
import webtest
from django.conf import settings
+from django.contrib.auth import login
from django.contrib.auth.models import Group
+from django.contrib.staticfiles.handlers import StaticFilesHandler
from django.db import DEFAULT_DB_ALIAS, connections
-from django.http.request import QueryDict
+from django.http.request import HttpRequest, QueryDict
+from django.test.runner import DiscoverRunner
+from django.test.selenium import SeleniumTestCase
from django.test.utils import CaptureQueriesContext
from django.utils import timezone, translation
from model_bakery import baker
+from selenium.webdriver.firefox.webdriver import WebDriver
+from selenium.webdriver.support.wait import WebDriverWait
from evap.evaluation.models import (
CHOICES,
@@ -29,6 +36,15 @@
)
+class EvapTestRunner(DiscoverRunner):
+ """Skips selenium tests by default, if no other tags are specified."""
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(*args, **kwargs)
+ if not self.tags and not self.exclude_tags:
+ self.exclude_tags = {"selenium"}
+
+
class ResetLanguageOnTearDownMixin:
def tearDown(self):
translation.activate("en") # Django by default does not "reset" this, causing test interdependency
@@ -102,36 +118,6 @@ def let_user_vote_for_evaluation(user, evaluation, create_answers=False):
RatingAnswerCounter.objects.bulk_update(rac_by_contribution_question.values(), ["count"])
-def store_ts_test_asset(relative_path: str, content) -> None:
- absolute_path = settings.STATICFILES_DIRS[0] / "ts" / "rendered" / relative_path
-
- absolute_path.parent.mkdir(parents=True, exist_ok=True)
-
- with open(absolute_path, "wb") as file:
- file.write(content)
-
-
-def render_pages(test_item):
- """Decorator which annotates test methods which render pages.
- The containing class is expected to include a `url` attribute which matches a valid path.
- Unlike normal test methods, it should not assert anything and is expected to return a dictionary.
- The key denotes the variant of the page to reflect multiple states, cases or views.
- The value is a byte string of the page content."""
-
- @functools.wraps(test_item)
- def decorator(self) -> None:
- pages = test_item(self)
-
- url = getattr(self, "render_pages_url", self.url)
-
- for name, content in pages.items():
- # Remove the leading slash from the url to prevent that an absolute path is created
- path = os.path.join(url[1:], f"{name}.html")
- store_ts_test_asset(path, content)
-
- return decorator
-
-
class WebTestWith200Check(WebTest):
url = "/"
test_users: list[UserProfile | str] = []
@@ -273,3 +259,59 @@ def assert_no_database_modifications(*args, **kwargs):
lower_sql = query["sql"].lower()
if not any(lower_sql.startswith(prefix) for prefix in allowed_prefixes):
raise AssertionError("Unexpected modifying query found: " + query["sql"])
+
+
+class LiveServerTest(SeleniumTestCase):
+ browser = "firefox"
+ selenium: WebDriver
+ headless = True
+ window_size = (1920, 4096) # large height to workaround scrolling
+ serialized_rollback = True # SeleniumTestCase is a TransactionTestCase, which drops migration data. This keeps fixture data but may slow down tests, see https://docs.djangoproject.com/en/5.0/topics/testing/overview/#test-case-serialized-rollback
+ static_handler = StaticFilesHandler # see StaticLiveServerTestCase
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.request = self.make_request()
+ self.manager = make_manager()
+ self.selenium.get(self.live_server_url)
+ self.login(self.manager)
+
+ @classmethod
+ def make_request(cls) -> HttpRequest:
+ request = HttpRequest()
+ engine = import_module(settings.SESSION_ENGINE)
+ request.session = engine.SessionStore()
+ return request
+
+ def update_session(self) -> None:
+ self.request.session.save()
+ self.selenium.add_cookie(
+ {
+ "name": settings.SESSION_COOKIE_NAME,
+ "value": self.request.session.session_key,
+ "path": "/",
+ "secure": settings.SESSION_COOKIE_SECURE or False,
+ }
+ )
+
+ def login(self, user) -> None:
+ """Login a test user by setting the session cookie."""
+ login(self.request, user, "evap.evaluation.auth.RequestAuthUserBackend")
+ self.update_session()
+
+ @contextmanager
+ def enter_staff_mode(self) -> Iterator[None]:
+ self.request.session["staff_mode_start_time"] = time.time()
+ self.update_session()
+ yield
+ del self.request.session["staff_mode_start_time"]
+ self.update_session()
+
+ @property
+ def wait(self) -> WebDriverWait:
+ return WebDriverWait(self.selenium, 10)
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ cls.selenium.set_window_size(*cls.window_size)
diff --git a/evap/results/tests/test_live.py b/evap/results/tests/test_live.py
new file mode 100644
index 0000000000..d0a8279a82
--- /dev/null
+++ b/evap/results/tests/test_live.py
@@ -0,0 +1,181 @@
+from typing import Any
+
+from django.urls import reverse
+from model_bakery import baker
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.expected_conditions import (
+ invisibility_of_element_located,
+ visibility_of_element_located,
+)
+
+from evap.evaluation.models import Course, CourseType, Evaluation, Program, Semester, UserProfile
+from evap.evaluation.tests.tools import LiveServerTest
+
+
+class ResultsIndexLiveTests(LiveServerTest):
+ def setUp(self):
+ super().setUp()
+
+ def make_winter_semester(year: int) -> Semester:
+ return baker.make(
+ Semester,
+ name_de=f"Wintersemester {year}/{year + 1}",
+ name_en=f"Winter term {year}/{year + 1}",
+ short_name_de=f"WS {year % 1000}/{year % 1000 + 1}",
+ short_name_en=f"WT {year % 1000}/{year % 1000 + 1}",
+ )
+
+ def make_summer_semester(year: int) -> Semester:
+ return baker.make(
+ Semester,
+ name_de=f"Sommersemester {year}",
+ name_en=f"Summer term {year}",
+ short_name_de=f"SS {year % 1000}",
+ short_name_en=f"ST {year % 1000}",
+ )
+
+ semesters = [
+ make_summer_semester(2014),
+ make_winter_semester(2013),
+ make_summer_semester(2013),
+ ]
+ programs = {
+ "ba-a": baker.make(Program, name_de="Bachelor A", name_en="Bachelor A"),
+ "ma-a": baker.make(Program, name_de="Master A", name_en="Master A"),
+ "ma-b": baker.make(Program, name_de="Master B", name_en="Master B"),
+ }
+ course_types = {
+ "l": baker.make(CourseType, name_de="Vorlesung", name_en="Lecture"),
+ "s": baker.make(CourseType, name_de="Seminar", name_en="Seminar"),
+ }
+
+ def make_responsible(title: str, first_name: str, last_name: str) -> UserProfile:
+ return baker.make(
+ UserProfile,
+ title=title,
+ first_name_given=first_name,
+ last_name=last_name,
+ )
+
+ responsibles = {
+ "responsible": make_responsible("Prof. Dr.", "", "responsible"),
+ "goldwasser": make_responsible("Dr.", "Clara", "Goldwasser"),
+ "kuchenbuch": make_responsible("Dr.", "Tony", "Kuchenbuch"),
+ }
+
+ def make_course(name, semester, course_type_name, program_names, responsible_names):
+ return baker.make(
+ Course,
+ semester=semesters[semester],
+ name_de=f"Veranstaltung {name}",
+ name_en=f"Course {name}",
+ type=course_types[course_type_name],
+ programs={programs[program_name] for program_name in program_names},
+ responsibles={responsibles[responsible_name] for responsible_name in responsible_names},
+ )
+
+ courses = {
+ "a-0": make_course("A", 0, "l", {"ba-a"}, {"responsible"}),
+ "a-1": make_course("A", 1, "l", {"ba-a"}, {"responsible"}),
+ "a-2": make_course("A", 2, "l", {"ba-a"}, {"responsible"}),
+ "c": make_course("C", 0, "s", {"ba-a", "ma-a"}, {"goldwasser"}),
+ "d": make_course("D", 0, "l", {"ma-a", "ma-b"}, {"kuchenbuch", "goldwasser"}),
+ "e": make_course("E", 2, "s", {"ma-a"}, {"kuchenbuch"}),
+ }
+
+ def make_evaluation(course_name: str, participant_count: int, voter_count: int, **attrs: Any) -> None:
+ baker.make(
+ Evaluation,
+ state=Evaluation.State.PUBLISHED,
+ course=courses[course_name],
+ _participant_count=participant_count,
+ _voter_count=voter_count,
+ **attrs,
+ )
+
+ make_evaluation("a-0", 100, 80)
+ make_evaluation("a-1", 100, 85)
+ make_evaluation("a-2", 100, 80)
+ make_evaluation("a-2", 100, 75, name_de="Klausur", name_en="Exam")
+ make_evaluation("c", 20, 15)
+ make_evaluation("d", 50, 45)
+ make_evaluation("e", 5, 5)
+
+ self.url = self.live_server_url + reverse("results:index")
+
+ def assertRowsVisible(self, *rows: tuple[str, str]):
+ items = tuple(
+ (
+ row.find_element(By.CLASS_NAME, "evaluation-name").text,
+ row.find_element(By.CLASS_NAME, "semester-short-name").text,
+ )
+ for row in self.selenium.find_elements(By.CLASS_NAME, "heading-row")
+ )
+ self.assertEqual(items, rows)
+
+ def test_results_initially_sorted_by_evaluation_and_semester(self):
+ self.selenium.get(self.url)
+ self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click()
+
+ self.assertRowsVisible(
+ ("Course A", "ST 14"),
+ ("Course A", "WT 13/14"),
+ ("Course A", "ST 13"),
+ ("Course C", "ST 14"),
+ ("Course D", "ST 14"),
+ ("Course E", "ST 13"),
+ )
+
+ def test_results_filter_with_search_input(self):
+ self.selenium.get(self.url)
+ self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click()
+
+ self.selenium.find_element(By.CSS_SELECTOR, "input[name=search]").send_keys("Exam")
+ self.wait.until(invisibility_of_element_located((By.XPATH, "//span[contains(text(),'Course C')]")))
+
+ self.assertRowsVisible(("Course A", "ST 13"))
+
+ def test_results_filter_with_program_checkbox(self):
+ self.selenium.get(self.url)
+ self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click()
+
+ self.selenium.find_element(By.CSS_SELECTOR, "input[name=program][data-filter='Bachelor A']").click()
+ self.assertRowsVisible(
+ ("Course A", "ST 14"), ("Course A", "WT 13/14"), ("Course A", "ST 13"), ("Course C", "ST 14")
+ )
+
+ def test_results_filter_with_course_type_checkbox(self):
+ self.selenium.get(self.url)
+ self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click()
+
+ self.selenium.find_element(By.CSS_SELECTOR, "input[name=courseType][data-filter='Seminar']").click()
+ self.assertRowsVisible(("Course C", "ST 14"), ("Course E", "ST 13"))
+
+ def test_results_filter_with_semester_checkbox(self):
+ self.selenium.get(self.url)
+ self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click()
+
+ self.selenium.find_element(By.CSS_SELECTOR, "input[name=semester][data-filter='ST 13']").click()
+ self.assertRowsVisible(("Course A", "ST 13"), ("Course E", "ST 13"))
+
+ def test_results_clear_filter(self):
+ self.selenium.get(self.url)
+ reset_button = self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button")))
+ reset_button.click()
+
+ search_input = self.selenium.find_element(By.CSS_SELECTOR, "input[name=search]")
+ program_checkbox = self.selenium.find_element(By.CSS_SELECTOR, "input[name=program][data-filter='Bachelor A']")
+ course_type_checkbox = self.selenium.find_element(
+ By.CSS_SELECTOR, "input[name=courseType][data-filter='Lecture']"
+ )
+ semester_checkbox = self.selenium.find_element(By.CSS_SELECTOR, "input[name=semester][data-filter='ST 14']")
+ search_input.send_keys("Some search text")
+ program_checkbox.click()
+ course_type_checkbox.click()
+ semester_checkbox.click()
+ reset_button.click()
+
+ self.assertEqual(search_input.get_attribute("value"), "")
+ self.assertFalse(program_checkbox.is_selected())
+ self.assertFalse(course_type_checkbox.is_selected())
+ self.assertFalse(semester_checkbox.is_selected())
diff --git a/evap/results/tests/test_views.py b/evap/results/tests/test_views.py
index 0abf6590cb..09f82171f6 100644
--- a/evap/results/tests/test_views.py
+++ b/evap/results/tests/test_views.py
@@ -12,7 +12,6 @@
from evap.evaluation.models import (
Contribution,
Course,
- CourseType,
Evaluation,
Program,
Question,
@@ -28,7 +27,6 @@
let_user_vote_for_evaluation,
make_manager,
make_rating_answer_counters,
- render_pages,
)
from evap.results.exporters import TextAnswerExporter
from evap.results.tools import cache_results
@@ -39,97 +37,6 @@
class TestResultsView(WebTest):
url = "/results/"
- @render_pages
- def render_pages(self):
- def make_winter_semester(year):
- return baker.make(
- Semester,
- name_de=f"Wintersemester {year}/{year + 1}",
- name_en=f"Winter term {year}/{year + 1}",
- short_name_de=f"WS {year % 1000}/{year % 1000 + 1}",
- short_name_en=f"WT {year % 1000}/{year % 1000 + 1}",
- )
-
- def make_summer_semester(year):
- return baker.make(
- Semester,
- name_de=f"Sommersemester {year}",
- name_en=f"Summer term {year}",
- short_name_de=f"SS {year % 1000}",
- short_name_en=f"ST {year % 1000}",
- )
-
- semesters = [
- make_summer_semester(2014),
- make_winter_semester(2013),
- make_summer_semester(2013),
- ]
- programs = {
- "ba-a": baker.make(Program, name_de="Bachelor A", name_en="Bachelor A"),
- "ma-a": baker.make(Program, name_de="Master A", name_en="Master A"),
- "ma-b": baker.make(Program, name_de="Master B", name_en="Master B"),
- }
- course_types = {
- "l": baker.make(CourseType, name_de="Vorlesung", name_en="Lecture"),
- "s": baker.make(CourseType, name_de="Seminar", name_en="Seminar"),
- }
-
- def make_responsible(title, first_name, last_name):
- return baker.make(
- UserProfile,
- title=title,
- first_name_given=first_name,
- last_name=last_name,
- )
-
- responsibles = {
- "responsible": make_responsible("Prof. Dr.", "", "responsible"),
- "goldwasser": make_responsible("Dr.", "Clara", "Goldwasser"),
- "kuchenbuch": make_responsible("Dr.", "Tony", "Kuchenbuch"),
- }
-
- def make_course(name, semester, course_type_name, program_names, responsible_names):
- return baker.make(
- Course,
- semester=semesters[semester],
- name_de=f"Veranstaltung {name}",
- name_en=f"Course {name}",
- type=course_types[course_type_name],
- programs={programs[program_name] for program_name in program_names},
- responsibles={responsibles[responsible_name] for responsible_name in responsible_names},
- )
-
- courses = {
- "a-0": make_course("A", 0, "l", {"ba-a"}, {"responsible"}),
- "a-1": make_course("A", 1, "l", {"ba-a"}, {"responsible"}),
- "a-2": make_course("A", 2, "l", {"ba-a"}, {"responsible"}),
- "c": make_course("C", 0, "s", {"ba-a", "ma-a"}, {"goldwasser"}),
- "d": make_course("D", 0, "l", {"ma-a", "ma-b"}, {"kuchenbuch", "goldwasser"}),
- "e": make_course("E", 2, "s", {"ma-a"}, {"kuchenbuch"}),
- }
-
- def make_evaluation(course_name, participant_count, voter_count, **attrs):
- baker.make(
- Evaluation,
- state=Evaluation.State.PUBLISHED,
- course=courses[course_name],
- _participant_count=participant_count,
- _voter_count=voter_count,
- **attrs,
- )
-
- make_evaluation("a-0", 100, 80)
- make_evaluation("a-1", 100, 85)
- make_evaluation("a-2", 100, 80)
- make_evaluation("a-2", 100, 75, name_de="Klausur", name_en="Exam")
- make_evaluation("c", 20, 15)
- make_evaluation("d", 50, 45)
- make_evaluation("e", 5, 5)
-
- return {
- "student": self.app.get(self.url, user="student@institution.example.com").content,
- }
-
@patch("evap.evaluation.models.Evaluation.can_be_seen_by", new=(lambda self, user: True))
def test_multiple_evaluations_per_course(self):
student = baker.make(UserProfile, email="student@institution.example.com")
diff --git a/evap/settings.py b/evap/settings.py
index 8677b7fbf8..3a447f1c97 100644
--- a/evap/settings.py
+++ b/evap/settings.py
@@ -439,6 +439,7 @@ def CHARACTER_ALLOWED_IN_NAME(character): # pylint: disable=invalid-name
except ImportError:
pass
+TEST_RUNNER = "evap.evaluation.tests.tools.EvapTestRunner"
TESTING = "test" in sys.argv or "pytest" in sys.modules
# speed up tests and activate typeguard introspection
diff --git a/evap/staff/templates/staff_evaluation_person_management.html b/evap/staff/templates/staff_evaluation_person_management.html
index 8bc3e5afea..fce46be853 100644
--- a/evap/staff/templates/staff_evaluation_person_management.html
+++ b/evap/staff/templates/staff_evaluation_person_management.html
@@ -220,7 +220,7 @@
{% translate 'To CSV file' %}
{% endblock %}
diff --git a/evap/staff/templates/staff_semester_import.html b/evap/staff/templates/staff_semester_import.html
index 407490b8bb..7356865e46 100644
--- a/evap/staff/templates/staff_semester_import.html
+++ b/evap/staff/templates/staff_semester_import.html
@@ -50,7 +50,7 @@ {% translate 'Import semester data' %}
{% include 'bootstrap_datetimepicker.html' %}
{% endblock %}
diff --git a/evap/staff/templates/staff_user_import.html b/evap/staff/templates/staff_user_import.html
index 7225d7077c..3d3ebf19df 100644
--- a/evap/staff/templates/staff_user_import.html
+++ b/evap/staff/templates/staff_user_import.html
@@ -53,6 +53,6 @@ {% translate 'Import users' %}
{% block additional_javascript %}
{% endblock %}
diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py
new file mode 100644
index 0000000000..e2a208aabd
--- /dev/null
+++ b/evap/staff/tests/test_live.py
@@ -0,0 +1,73 @@
+from datetime import date, datetime
+
+from django.urls import reverse
+from model_bakery import baker
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.expected_conditions import element_to_be_clickable, visibility_of_element_located
+
+from evap.evaluation.models import Contribution, Course, Evaluation, Program, Question, Questionnaire, UserProfile
+from evap.evaluation.tests.tools import LiveServerTest
+
+
+class EvaluationEditLiveTest(LiveServerTest):
+ def test_submit_changes_form_data(self):
+ """Regression test for #1769"""
+
+ responsible = baker.make(UserProfile)
+ evaluation = baker.make(
+ Evaluation,
+ course=baker.make(Course, programs=[baker.make(Program)], responsibles=[responsible]),
+ vote_start_datetime=datetime(2099, 1, 1, 0, 0),
+ vote_end_date=date(2099, 12, 31),
+ )
+
+ general_questionnaire = baker.make(Questionnaire, questions=[baker.make(Question)])
+ evaluation.general_contribution.questionnaires.set([general_questionnaire])
+
+ contribution1 = baker.make(
+ Contribution,
+ evaluation=evaluation,
+ contributor=responsible,
+ order=0,
+ role=Contribution.Role.CONTRIBUTOR,
+ textanswer_visibility=Contribution.TextAnswerVisibility.OWN_TEXTANSWERS,
+ )
+ baker.make(
+ Contribution,
+ evaluation=evaluation,
+ contributor=baker.make(UserProfile),
+ order=1,
+ role=Contribution.Role.EDITOR,
+ )
+
+ with self.enter_staff_mode():
+ self.selenium.get(self.live_server_url + reverse("staff:evaluation_edit", args=[evaluation.pk]))
+
+ row = self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#id_contributions-0-contributor")))
+ tomselect_options = row.get_property("tomselect")["options"]
+ manager_text = "manager (manager@institution.example.com)"
+ manager_options = [key for key, value in tomselect_options.items() if value["text"] == manager_text]
+ self.assertEqual(len(manager_options), 1)
+ self.selenium.execute_script(
+ f"""let tomselect = document.querySelector("#id_contributions-0-contributor").tomselect;
+ tomselect.setValue("{manager_options[0]}");"""
+ )
+
+ submit_btn = self.wait.until(
+ element_to_be_clickable((By.XPATH, "//button[@name='operation' and @value='save']"))
+ )
+
+ editor_labels = self.selenium.find_elements(By.XPATH, "//label[contains(text(), 'Editor')]")
+ own_and_general_labels = self.selenium.find_elements(By.XPATH, "//label[contains(text(), 'Own and general')]")
+ editor_labels[0].click()
+ own_and_general_labels[0].click()
+
+ with self.enter_staff_mode():
+ submit_btn.click()
+
+ contribution1.refresh_from_db()
+
+ self.assertEqual(contribution1.contributor_id, self.manager.id)
+ self.assertEqual(contribution1.order, 0)
+ self.assertEqual(contribution1.role, Contribution.Role.EDITOR)
+ self.assertEqual(contribution1.textanswer_visibility, Contribution.TextAnswerVisibility.GENERAL_TEXTANSWERS)
diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py
index c0fa4520c8..2247a65b7c 100644
--- a/evap/staff/tests/test_views.py
+++ b/evap/staff/tests/test_views.py
@@ -45,7 +45,6 @@
let_user_vote_for_evaluation,
make_manager,
make_rating_answer_counters,
- render_pages,
submit_with_modal,
)
from evap.grades.models import GradeDocument
@@ -610,12 +609,6 @@ def setUpTestData(cls):
cls.manager = make_manager()
- @render_pages
- def render_pages(self):
- return {
- "normal": self.app.get(self.url, user=self.manager).content,
- }
-
def test_success_handling(self):
"""
Tests whether a correct excel file is correctly tested and imported and whether the success messages are displayed
@@ -2159,8 +2152,6 @@ def get_post_params(cls):
]
)
class TestEvaluationEditView(WebTestStaffMode):
- render_pages_url = "/staff/semester/PK/evaluation/PK/edit"
-
@classmethod
def setUpTestData(cls):
cls.manager = make_manager()
@@ -2202,12 +2193,6 @@ def setUpTestData(cls):
cls.contribution1.questionnaires.set([cls.contributor_questionnaire])
cls.contribution2.questionnaires.set([cls.contributor_questionnaire])
- @render_pages
- def render_pages(self):
- return {
- "normal": self.app.get(self.url, user=self.manager).content,
- }
-
def test_edit_evaluation(self):
page = self.app.get(self.url, user=self.manager)
diff --git a/evap/static/ts/src/copy-to-clipboard.ts b/evap/static/ts/src/copy-to-clipboard.ts
index 8d22e756d6..24eead05de 100644
--- a/evap/static/ts/src/copy-to-clipboard.ts
+++ b/evap/static/ts/src/copy-to-clipboard.ts
@@ -1,19 +1,3 @@
-function copyToClipboard(text: string) {
- const selection = document.getSelection()!;
- const el = document.createElement("textarea");
- el.value = text;
- document.body.appendChild(el);
- const selected = selection.rangeCount > 0 ? selection.getRangeAt(0) : false;
- el.select();
- // eslint-disable-next-line @typescript-eslint/no-deprecated
- document.execCommand("copy"); // required by puppeteer tests
- el.remove();
- if (selected) {
- selection.removeAllRanges();
- selection.addRange(selected);
- }
-}
-
-function copyHeaders(headers: string[]) {
- copyToClipboard(headers.join("\t"));
+async function copyHeaders(headers: string[]): Promise {
+ await navigator.clipboard.writeText(headers.join("\t"));
}
diff --git a/evap/static/ts/tests/frontend/results-index.ts b/evap/static/ts/tests/frontend/results-index.ts
deleted file mode 100644
index 72d4dab0dd..0000000000
--- a/evap/static/ts/tests/frontend/results-index.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Page } from "puppeteer";
-
-import { pageHandler } from "../utils/page";
-import "../utils/matchers";
-
-async function fetchVisibleRows(page: Page): Promise {
- return await page.$$eval(".heading-row", rows =>
- rows.map(row => {
- const evaluationName = row.querySelector(".evaluation-name")!.textContent!.trim();
- const semester = row.querySelector(".semester-short-name")!.textContent!.trim();
- return [evaluationName, semester];
- }),
- );
-}
-
-test(
- "initially sort by evaluation and semester",
- pageHandler("results/student.html", async page => {
- expect(await fetchVisibleRows(page)).toEqual([
- ["Course A", "ST 14"],
- ["Course A", "WT 13/14"],
- ["Course A", "ST 13"],
- ["Course C", "ST 14"],
- ["Course D", "ST 14"],
- ["Course E", "ST 13"],
- ]);
- }),
-);
-
-test(
- "filter with search input",
- pageHandler("results/student.html", async page => {
- await page.type("input[name=search]", "Exam");
- await new Promise(resolve => setTimeout(resolve, 200)); // wait for input to debounce
-
- expect(await fetchVisibleRows(page)).toEqual([["Course A", "ST 13"]]);
- }),
-);
-
-test(
- "filter with program checkbox",
- pageHandler("results/student.html", async page => {
- await page.click("input[name=program][data-filter='Bachelor A']");
-
- expect(await fetchVisibleRows(page)).toEqual([
- ["Course A", "ST 14"],
- ["Course A", "WT 13/14"],
- ["Course A", "ST 13"],
- ["Course C", "ST 14"],
- ]);
- }),
-);
-
-test(
- "filter with course type checkbox",
- pageHandler("results/student.html", async page => {
- await page.click("input[name=courseType][data-filter=Seminar]");
-
- expect(await fetchVisibleRows(page)).toEqual([
- ["Course C", "ST 14"],
- ["Course E", "ST 13"],
- ]);
- }),
-);
-
-test(
- "filter with semester checkbox",
- pageHandler("results/student.html", async page => {
- await page.click("input[name=semester][data-filter='ST 13']");
-
- expect(await fetchVisibleRows(page)).toEqual([
- ["Course A", "ST 13"],
- ["Course E", "ST 13"],
- ]);
- }),
-);
-
-test(
- "clear filters",
- pageHandler("results/student.html", async page => {
- const searchInput = (await page.$("input[name=search]"))!;
- const programCheckbox = (await page.$("input[name=program][data-filter='Bachelor A']"))!;
- const courseTypeCheckbox = (await page.$("input[name=courseType][data-filter=Lecture]"))!;
- const semesterCheckbox = (await page.$("input[name=semester][data-filter='ST 14']"))!;
-
- await searchInput.type("Some search text");
- await programCheckbox.click();
- await courseTypeCheckbox.click();
- await semesterCheckbox.click();
-
- await page.click("[data-reset=filter]");
-
- expect(await searchInput.evaluate(searchInput => searchInput.value)).toBe("");
- await expect(programCheckbox).not.toBeChecked();
- await expect(courseTypeCheckbox).not.toBeChecked();
- await expect(semesterCheckbox).not.toBeChecked();
- }),
-);
diff --git a/evap/static/ts/tests/frontend/staff-evaluation-edit.ts b/evap/static/ts/tests/frontend/staff-evaluation-edit.ts
deleted file mode 100644
index 53de76d003..0000000000
--- a/evap/static/ts/tests/frontend/staff-evaluation-edit.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { test, expect } from "@jest/globals";
-import { ElementHandle } from "puppeteer";
-
-import { pageHandler } from "../utils/page";
-import { assertDefined } from "../../src/utils";
-
-// regression test for #1769
-test(
- "changes form data",
- pageHandler("/staff/semester/PK/evaluation/PK/edit/normal.html", async page => {
- const managerId = await page.evaluate(() => {
- const tomselect = (document.getElementById("id_contributions-0-contributor") as any).tomselect;
- const options = tomselect.options;
- const managerOption = Object.keys(options).find(
- key => options[key].text == "manager (manager@institution.example.com)",
- );
- tomselect.setValue(managerOption);
- return managerOption;
- });
- assertDefined(managerId);
-
- const editorLabels = await page.$$("xpath/.//label[contains(text(), 'Editor')]");
- const ownAndGeneralLabels = await page.$$("xpath/.//label[contains(text(), 'Own and general')]");
- if (editorLabels.length < 1 || ownAndGeneralLabels.length < 1) {
- throw new Error("Button group buttons not found.");
- }
-
- await editorLabels[0].click();
- await ownAndGeneralLabels[0].click();
-
- const formData = await page.evaluate(() =>
- Object.fromEntries(new FormData(document.getElementById("evaluation-form") as HTMLFormElement)),
- );
-
- expect(formData["contributions-0-contributor"]).toBe(managerId);
- expect(formData["contributions-0-order"]).toBe("0");
- expect(formData["contributions-0-role"]).toBe("1");
- expect(formData["contributions-0-textanswer_visibility"]).toBe("GENERAL");
- }),
-);
diff --git a/evap/static/ts/tests/frontend/staff-user-import.ts b/evap/static/ts/tests/frontend/staff-user-import.ts
deleted file mode 100644
index 95f9eafb78..0000000000
--- a/evap/static/ts/tests/frontend/staff-user-import.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { test, expect } from "@jest/globals";
-
-import { pageHandler } from "../utils/page";
-
-test(
- "copies header",
- pageHandler("staff/user/import/normal.html", async page => {
- await page.click(".btn-link");
- const copiedText = await page.evaluate(() => navigator.clipboard.readText());
- expect(copiedText).toBe("Title\tFirst name\tLast name\tEmail");
- }),
-);
diff --git a/evap/static/ts/tests/frontend/student-vote.ts b/evap/static/ts/tests/frontend/student-vote.ts
deleted file mode 100644
index 6def8a1601..0000000000
--- a/evap/static/ts/tests/frontend/student-vote.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { Page, ElementHandle } from "puppeteer";
-
-import { pageHandler } from "../utils/page";
-import "../utils/matchers";
-
-interface TextResultsPublishConfirmationElements {
- top: ElementHandle;
- bottom: ElementHandle;
- bottomCard: ElementHandle;
-}
-
-async function query(page: Page): Promise {
- return {
- top: (await page.$("#text_results_publish_confirmation_top"))!,
- bottom: (await page.$("#text_results_publish_confirmation_bottom"))!,
- bottomCard: (await page.$("#bottom_text_results_publish_confirmation_card"))!,
- };
-}
-
-async function queryClosest(element: ElementHandle, selector: string): Promise> {
- return element
- .evaluateHandle((element, selector) => element.closest(selector), selector)
- .then(handle => handle.asElement()!);
-}
-
-test(
- "checking top confirm checkbox checks and hides bottom",
- pageHandler("student/vote/PK/with_textanswer_publish_confirmation.html", async page => {
- const elements = await query(page);
- await elements.top.click();
-
- await expect(elements.bottom).toBeChecked();
- await expect(elements.bottomCard).toHaveClass("d-none");
- }),
-);
-
-test(
- "checking bottom confirm checkbox check top but keeps bottom visible",
- pageHandler("student/vote/PK/with_textanswer_publish_confirmation.html", async page => {
- const elements = await query(page);
- await elements.bottom.click();
-
- await expect(elements.top).toBeChecked();
- await expect(elements.bottomCard).not.toHaveClass("d-none");
- }),
-);
-
-test(
- "resolving submit errors clears warning",
- pageHandler("student/vote/PK/submit_errors.html", async page => {
- const checkbox = (await page.$(".choice-error + input[type=radio][value='3']"))!;
- await checkbox.click();
- const row = await queryClosest(checkbox, ".row");
- expect(await row.$$(".choice-error")).toHaveLength(0);
- }),
-);
-
-test(
- "skip contributor",
- pageHandler("student/vote/PK/normal.html", async page => {
- const button = (await page.$("[data-mark-no-answers-for]"))!;
- const voteArea = (await queryClosest(button, ".card").then(card => card.$(".collapse")))!;
- await button.click();
- for (const checkbox of await voteArea.$$("input[type=radio]:not([value='6'])")) {
- await expect(checkbox).not.toBeChecked();
- }
- for (const checkbox of await voteArea.$$("input[type=radio][value='6']")) {
- await expect(checkbox).toBeChecked();
- }
- await expect(voteArea).toHaveClass("collapsing");
- }),
-);
-
-test(
- "skipping contributor clears warning",
- pageHandler("student/vote/PK/submit_errors.html", async page => {
- const button = (await page.$("[data-mark-no-answers-for]"))!;
- const voteArea = (await queryClosest(button, ".card").then(card => card.$(".collapse")))!;
- await button.click();
- expect(await voteArea.$$(".choice-error")).toHaveLength(0);
- }),
-);
diff --git a/evap/static/ts/tests/utils/matchers.ts b/evap/static/ts/tests/utils/matchers.ts
deleted file mode 100644
index 6f442ca7de..0000000000
--- a/evap/static/ts/tests/utils/matchers.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { ElementHandle } from "puppeteer";
-import MatcherUtils = jest.MatcherUtils;
-
-declare global {
- namespace jest {
- interface Matchers {
- toBeChecked(): Promise;
-
- toHaveClass(className: string): Promise;
- }
- }
-}
-
-function createTagDescription(element: ElementHandle): Promise {
- return element.evaluate(element => {
- let tagDescription = element.tagName.toLowerCase();
- if (element.id) {
- tagDescription += ` id="${element.id}"`;
- }
- if (element.className) {
- tagDescription += ` class="${element.className}"`;
- }
- return `<${tagDescription}>`;
- });
-}
-
-async function createElementMessage(
- this: MatcherUtils,
- matcherName: string,
- expectation: string,
- element: ElementHandle,
- value?: any,
-): Promise<() => string> {
- const tagDescription = await createTagDescription(element);
- return () => {
- const optionallyNot = this.isNot ? "not " : "";
- const receivedLine = value ? `\nReceived: ${this.utils.printReceived(value)}` : "";
- return (
- this.utils.matcherHint(matcherName, undefined, undefined, { isNot: this.isNot }) +
- "\n\n" +
- `Expected ${this.utils.RECEIVED_COLOR(tagDescription)} to ${optionallyNot}${expectation}` +
- receivedLine
- );
- };
-}
-
-expect.extend({
- async toBeChecked(received: ElementHandle): Promise {
- const pass = await received.evaluate(element => (element as HTMLInputElement).checked);
- const message = await createElementMessage.call(this, "toBeChecked", "be checked", received);
- return { message, pass };
- },
-
- async toHaveClass(received: ElementHandle, className: string): Promise {
- const classList = await received.evaluate(element => [...element.classList]);
- const pass = classList.includes(className);
- const message = await createElementMessage.call(
- this,
- "toHaveClass",
- `have the class ${this.utils.printExpected(className)}`,
- received,
- classList,
- );
-
- return { message, pass };
- },
-});
diff --git a/evap/static/ts/tests/utils/page.ts b/evap/static/ts/tests/utils/page.ts
deleted file mode 100644
index 8431f0caa7..0000000000
--- a/evap/static/ts/tests/utils/page.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import * as fs from "fs";
-import * as path from "path";
-import { Browser, Page } from "puppeteer";
-import { Global } from "@jest/types/";
-import DoneFn = Global.DoneFn;
-
-const contentTypeByExtension = new Map([
- [".css", "text/css"],
- [".js", "application/javascript"],
- [".png", "image/png"],
- [".svg", "image/svg+xml"],
-]);
-
-async function createPage(browser: Browser): Promise {
- const staticPrefix = "/static/";
-
- const page = await browser.newPage();
- await page.setRequestInterception(true);
- page.on("request", async request => {
- const extension = path.extname(request.url());
- const pathname = new URL(request.url()).pathname;
- if (extension === ".html") {
- // requests like /evap/evap/static/ts/rendered/results/student.html
- await request.continue();
- } else if (pathname.startsWith(staticPrefix)) {
- // requests like /static/css/tom-select.bootstrap5.min.css
- const asset = pathname.substring(staticPrefix.length);
- const body = fs.readFileSync(path.join(__dirname, "..", "..", "..", asset));
- await request.respond({
- contentType: contentTypeByExtension.get(extension),
- body,
- });
- } else if (pathname.endsWith("catalog.js")) {
- // request for /catalog.js
- // some pages will error out if translation functions are not available
- // rendered in RenderJsTranslationCatalog
- const absolute_fs_path = path.join(__dirname, "..", "..", "..", "ts", "rendered", "catalog.js");
- const body = fs.readFileSync(absolute_fs_path);
- await request.respond({
- contentType: contentTypeByExtension.get(extension),
- body,
- });
- } else {
- await request.abort();
- }
- });
- return page;
-}
-
-export function pageHandler(fileName: string, fn: (page: Page) => Promise): (done?: DoneFn) => Promise {
- return async done => {
- let finished = false;
- // This wrapper ensures that done() is only called once
- async function finish(reason?: Error) {
- if (!finished) {
- finished = true;
- await page.evaluate(() => {
- localStorage.clear();
- });
- await page.close();
- done!(reason);
- }
- }
-
- const context = browser.defaultBrowserContext();
- await context.overridePermissions("file:", ["clipboard-read"]);
-
- const page = await createPage(browser);
- page.on("pageerror", async error => {
- await finish(new Error(error.message));
- });
-
- const filePath = path.join(__dirname, "..", "..", "rendered", fileName);
- await page.goto(`file:${filePath}`, { waitUntil: "networkidle0" });
-
- try {
- await fn(page);
- await finish();
- } catch (error) {
- if (error instanceof Error) await finish(error);
- else throw error;
- }
- };
-}
diff --git a/evap/student/tests/test_live.py b/evap/student/tests/test_live.py
new file mode 100644
index 0000000000..aa40d0d16d
--- /dev/null
+++ b/evap/student/tests/test_live.py
@@ -0,0 +1,120 @@
+from django.test import override_settings
+from django.urls import reverse
+from model_bakery import baker
+from selenium.webdriver.common.by import By
+from selenium.webdriver.remote.webelement import WebElement
+from selenium.webdriver.support.expected_conditions import presence_of_element_located, visibility_of_element_located
+
+from evap.evaluation.models import Contribution, Evaluation, Question, Questionnaire, QuestionType, UserProfile
+from evap.evaluation.tests.tools import LiveServerTest
+
+
+class StudentVoteLiveTest(LiveServerTest):
+
+ def setUp(self) -> None:
+ super().setUp()
+ voting_user1 = baker.make(UserProfile, email="voting_user1@institution.example.com")
+ voting_user2 = baker.make(UserProfile, email="voting_user2@institution.example.com")
+ contributor1 = baker.make(UserProfile, email="contributor1@institution.example.com")
+ contributor2 = baker.make(UserProfile, email="contributor2@institution.example.com")
+
+ evaluation = baker.make(
+ Evaluation,
+ participants=[voting_user1, voting_user2, contributor1],
+ state=Evaluation.State.IN_EVALUATION,
+ )
+
+ top_general_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP)
+ bottom_general_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.BOTTOM)
+ contributor_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.CONTRIBUTOR)
+
+ baker.make(Question, questionnaire=contributor_questionnaire, order=0, type=QuestionType.HEADING)
+ baker.make(Question, questionnaire=contributor_questionnaire, order=1, type=QuestionType.TEXT)
+ baker.make(Question, questionnaire=contributor_questionnaire, order=2, type=QuestionType.POSITIVE_LIKERT)
+
+ baker.make(Question, questionnaire=top_general_questionnaire, order=0, type=QuestionType.HEADING)
+ baker.make(Question, questionnaire=top_general_questionnaire, order=1, type=QuestionType.TEXT)
+ baker.make(Question, questionnaire=top_general_questionnaire, order=2, type=QuestionType.POSITIVE_LIKERT)
+ baker.make(Question, questionnaire=top_general_questionnaire, order=3, type=QuestionType.GRADE)
+
+ baker.make(Question, questionnaire=bottom_general_questionnaire, order=0, type=QuestionType.HEADING)
+ baker.make(Question, questionnaire=bottom_general_questionnaire, order=1, type=QuestionType.TEXT)
+ baker.make(Question, questionnaire=bottom_general_questionnaire, order=2, type=QuestionType.POSITIVE_LIKERT)
+ baker.make(Question, questionnaire=bottom_general_questionnaire, order=3, type=QuestionType.GRADE)
+
+ baker.make(
+ Contribution,
+ contributor=contributor1,
+ questionnaires=[contributor_questionnaire],
+ evaluation=evaluation,
+ )
+ baker.make(
+ Contribution,
+ contributor=contributor2,
+ questionnaires=[contributor_questionnaire],
+ evaluation=evaluation,
+ )
+
+ evaluation.general_contribution.questionnaires.set([top_general_questionnaire, bottom_general_questionnaire])
+ self.url = self.live_server_url + reverse("student:vote", args=[evaluation.pk])
+ self.login(voting_user1)
+
+ def _get_publish_confirmation(self) -> dict[str, WebElement]:
+ return {
+ "top": self.wait.until(visibility_of_element_located((By.ID, "text_results_publish_confirmation_top"))),
+ "bottom": self.selenium.find_element(By.ID, "text_results_publish_confirmation_bottom"),
+ "bottom_card": self.selenium.find_element(By.ID, "bottom_text_results_publish_confirmation_card"),
+ }
+
+ def test_checking_top_confirm_checkbox_checks_and_hides_bottom(self):
+ self.selenium.get(self.url)
+ confirmation = self._get_publish_confirmation()
+
+ confirmation["top"].click()
+
+ self.assertTrue(confirmation["bottom"].is_selected())
+ self.assertIn("d-none", confirmation["bottom_card"].get_attribute("class"))
+
+ def test_checking_bottom_confirm_checkbox_check_top_but_keeps_bottom_visible(self):
+ self.selenium.get(self.url)
+ confirmation = self._get_publish_confirmation()
+
+ confirmation["bottom"].click()
+
+ self.assertTrue(confirmation["top"].is_selected())
+ self.assertNotIn("d-none", confirmation["bottom_card"].get_attribute("class"))
+
+ def test_resolving_submit_errors_clears_warning(self) -> None:
+ self.selenium.get(self.url)
+ self.wait.until(presence_of_element_located((By.ID, "vote-submit-btn"))).click()
+
+ row = self.selenium.find_element(By.CSS_SELECTOR, "#student-vote-form .row:has(.btn-check)")
+ checkbox = row.find_element(By.CSS_SELECTOR, "input[type=radio][value='2'] + label.choice-error")
+ checkbox.click()
+ self.assertEqual(row.find_elements(By.CSS_SELECTOR, ".choice-error"), [])
+
+ @override_settings(SMALL_COURSE_SIZE=2)
+ def test_skip_contributor(self) -> None:
+ self.selenium.get(self.url)
+
+ button = self.wait.until(presence_of_element_located((By.CSS_SELECTOR, "[data-mark-no-answers-for]")))
+ button.click()
+ id_ = button.get_attribute("data-mark-no-answers-for")
+ vote_area = self.selenium.find_element(By.ID, f"vote-area-{id_}")
+
+ for checkbox in vote_area.find_elements(By.CSS_SELECTOR, "input[type=radio]:not([value='6'])"):
+ self.assertFalse(checkbox.is_selected())
+
+ for checkbox in vote_area.find_elements(By.CSS_SELECTOR, "input[type=radio][value='6']"):
+ self.assertTrue(checkbox.is_selected())
+
+ self.assertIn(vote_area.get_attribute("class"), ("collapsing", "collapse"))
+
+ @override_settings(SMALL_COURSE_SIZE=2)
+ def test_skip_contributor_clears_warning(self) -> None:
+ self.selenium.get(self.url)
+ button = self.wait.until(presence_of_element_located((By.CSS_SELECTOR, "[data-mark-no-answers-for]")))
+ button.click()
+
+ id_ = button.get_attribute("data-mark-no-answers-for")
+ self.assertEqual(len(self.selenium.find_elements(By.CSS_SELECTOR, f"#vote-area-{id_} .choice-error")), 0)
diff --git a/evap/student/tests/test_views.py b/evap/student/tests/test_views.py
index 455c1c6762..0aaa22ae14 100644
--- a/evap/student/tests/test_views.py
+++ b/evap/student/tests/test_views.py
@@ -18,7 +18,7 @@
UserProfile,
VoteTimestamp,
)
-from evap.evaluation.tests.tools import FuzzyInt, WebTest, WebTestWith200Check, render_pages
+from evap.evaluation.tests.tools import FuzzyInt, WebTest, WebTestWith200Check
from evap.student.tools import answer_field_id
from evap.student.views import SUCCESS_MAGIC_STRING
@@ -145,8 +145,6 @@ def test_global_reward_progress_hidden(self):
@override_settings(INSTITUTION_EMAIL_DOMAINS=["example.com"])
class TestVoteView(WebTest):
- render_pages_url = "/student/vote/PK"
-
@classmethod
def setUpTestData(cls):
cls.voting_user1 = baker.make(UserProfile, email="voting_user1@institution.example.com")
@@ -218,20 +216,6 @@ def setUpTestData(cls):
[cls.top_general_questionnaire, cls.bottom_general_questionnaire]
)
- @render_pages
- def render_pages(self):
- with_confirmation = self.app.get(self.url, user=self.voting_user1)
- submit_errors = with_confirmation.forms["student-vote-form"].submit()
-
- with override_settings(SMALL_COURSE_SIZE=2):
- normal = self.app.get(self.url, user=self.voting_user1)
-
- return {
- "with_textanswer_publish_confirmation": with_confirmation.content,
- "submit_errors": submit_errors.content,
- "normal": normal.content,
- }
-
def test_question_ordering(self):
page = self.app.get(self.url, user=self.voting_user1, status=200)
diff --git a/flake.nix b/flake.nix
index 5cf247f535..29686fe1dd 100644
--- a/flake.nix
+++ b/flake.nix
@@ -45,6 +45,7 @@
workspaceRoot = ./.;
};
evap-dev = evap.override (prev: { dependency-groups = (prev.dependency-groups or [ ]) ++ [ "dev" ]; });
+ evap-frontend-dev = evap-dev.overrideAttrs (prev: { nativeBuildInputs = (prev.nativeBuildInputs or [ ]) ++ (with pkgs; [ firefox geckodriver ]); });
default = evap-dev;
impure = pkgs.mkShell {
diff --git a/package.json b/package.json
index c5b9eda606..4e0b538dae 100644
--- a/package.json
+++ b/package.json
@@ -2,17 +2,14 @@
"devDependencies": {
"@types/bootstrap": "^5.2.10",
"@types/jest": "^29.5.12",
- "@types/jest-environment-puppeteer": "^5.0.3",
"@types/jquery": "^3.5.16",
"@types/sortablejs": "^1.3.32",
"@eslint/js": "^9.20.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
- "jest-environment-puppeteer": "^11.0.0",
"jest-jasmine2": "^29.7.0",
"jest-ts-webcompat-resolver": "^1.0.0",
"prettier": "^3.5.1",
- "puppeteer": "^24.0.0",
"sass": "1.77.1",
"ts-jest": "^29.2.0",
"typescript": "^5.7.2",
@@ -32,9 +29,6 @@
"transform": {
"^.+\\.ts$": "ts-jest"
},
- "globalSetup": "jest-environment-puppeteer/setup",
- "globalTeardown": "jest-environment-puppeteer/teardown",
- "testEnvironment": "jest-environment-puppeteer",
"resolver": "jest-ts-webcompat-resolver"
},
"type": "module"
diff --git a/pyproject.toml b/pyproject.toml
index 1004e6cf20..a6f9c6c7bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,7 @@ dev = [
"tblib~=3.0.0",
"xlrd~=2.0.1",
"typeguard~=4.4.0",
+ "selenium~=4.27.0",
]
[tool.uv]
diff --git a/uv.lock b/uv.lock
index 01f675ccd7..7ea3312a80 100644
--- a/uv.lock
+++ b/uv.lock
@@ -34,6 +34,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 },
]
+[[package]]
+name = "attrs"
+version = "24.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 },
+]
+
[[package]]
name = "beautifulsoup4"
version = "4.12.3"
@@ -348,6 +357,7 @@ dev = [
{ name = "pylint" },
{ name = "pylint-django" },
{ name = "ruff" },
+ { name = "selenium" },
{ name = "tblib" },
{ name = "typeguard" },
{ name = "xlrd" },
@@ -382,11 +392,30 @@ dev = [
{ name = "pylint", specifier = "~=3.3.0" },
{ name = "pylint-django", specifier = "~=2.6.1" },
{ name = "ruff", specifier = "~=0.8.0" },
+ { name = "selenium", specifier = "~=4.27.0" },
{ name = "tblib", specifier = "~=3.0.0" },
{ name = "typeguard", specifier = "~=4.4.0" },
{ name = "xlrd", specifier = "~=2.0.1" },
]
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
+]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
+]
+
[[package]]
name = "idna"
version = "3.10"
@@ -508,6 +537,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/04/5847e2e75c39f80e65c388f8e292719b46c00cda2c987f2d5c53ed617d11/openpyxl_stubs-0.1.25-py3-none-any.whl", hash = "sha256:db29f7804993b4a46b155fc4be45314c14538cb475b00591d8096e5af486abf1", size = 26598 },
]
+[[package]]
+name = "outcome"
+version = "1.3.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 },
+]
+
[[package]]
name = "packaging"
version = "24.2"
@@ -645,6 +686,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/dd/e0aa7ebef5168c75b772eda64978c597a9129b46be17779054652a7999e4/pyOpenSSL-24.2.1-py3-none-any.whl", hash = "sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d", size = 58390 },
]
+[[package]]
+name = "pysocks"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 },
+]
+
[[package]]
name = "redis"
version = "5.2.0"
@@ -697,6 +747,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 },
]
+[[package]]
+name = "selenium"
+version = "4.27.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "trio" },
+ { name = "trio-websocket" },
+ { name = "typing-extensions" },
+ { name = "urllib3", extra = ["socks"] },
+ { name = "websocket-client" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/8c/62c47c91072aa03af1c3b7d7f1c59b987db41c9fec0f158fb03a0da51aa6/selenium-4.27.1.tar.gz", hash = "sha256:5296c425a75ff1b44d0d5199042b36a6d1ef76c04fb775b97b40be739a9caae2", size = 973526 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a6/1e/5f1a5dd2a28528c4b3ec6e076b58e4c035810c805328f9936123283ca14e/selenium-4.27.1-py3-none-any.whl", hash = "sha256:b89b1f62b5cfe8025868556fe82360d6b649d464f75d2655cb966c8f8447ea18", size = 9707007 },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 },
+]
+
[[package]]
name = "soupsieve"
version = "2.6"
@@ -742,6 +827,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 },
]
+[[package]]
+name = "trio"
+version = "0.27.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
+ { name = "exceptiongroup" },
+ { name = "idna" },
+ { name = "outcome" },
+ { name = "sniffio" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/d1/a83dee5be404da7afe5a71783a33b8907bacb935a6dc8c69ab785e4a3eed/trio-0.27.0.tar.gz", hash = "sha256:1dcc95ab1726b2da054afea8fd761af74bad79bd52381b84eae408e983c76831", size = 568064 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/83/ec3196c360afffbc5b342ead48d1eb7393dd74fa70bca75d33905a86f211/trio-0.27.0-py3-none-any.whl", hash = "sha256:68eabbcf8f457d925df62da780eff15ff5dc68fd6b367e2dde59f7aaf2a0b884", size = 481734 },
+]
+
+[[package]]
+name = "trio-websocket"
+version = "0.11.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "exceptiongroup" },
+ { name = "trio" },
+ { name = "wsproto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/36/abad2385853077424a11b818d9fd8350d249d9e31d583cb9c11cd4c85eda/trio-websocket-0.11.1.tar.gz", hash = "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f", size = 26511 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/be/a9ae5f50cad5b6f85bd2574c2c923730098530096e170c1ce7452394d7aa/trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638", size = 17408 },
+]
+
[[package]]
name = "typeguard"
version = "4.4.1"
@@ -790,6 +907,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 },
]
+[package.optional-dependencies]
+socks = [
+ { name = "pysocks" },
+]
+
[[package]]
name = "waitress"
version = "3.0.2"
@@ -808,6 +930,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/bd/c336448be43d40be28e71f2e0f3caf7ccb28e2755c58f4c02c065bfe3e8e/WebOb-1.8.9-py2.py3-none-any.whl", hash = "sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9", size = 115364 },
]
+[[package]]
+name = "websocket-client"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 },
+]
+
[[package]]
name = "webtest"
version = "3.0.1"
@@ -822,6 +953,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/6d/075023456a2ff8e01ef07afa069563f0d1e1a2fd359d7dbd7672a5bf218a/WebTest-3.0.1-py3-none-any.whl", hash = "sha256:b3bc75d020d0576ee93a5f149666045e58fe2400ea5f0c214d7430d7d213d0d0", size = 32154 },
]
+[[package]]
+name = "wsproto"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 },
+]
+
[[package]]
name = "xlrd"
version = "2.0.1"