Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ jobs:
run: |
docker compose -p cms -f docker/docker-compose.test.yml run --rm testcms

- name: Run browser tests
run: |
docker compose -p cms -f docker/docker-compose.test.yml run --rm browsertestcms

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
Expand Down
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ COPY --chown=cmsuser:cmsuser . /home/cmsuser/src

RUN --mount=type=cache,target=/home/cmsuser/.cache/pip,uid=2000 ./install.py cms --devel

# Browser tests (cmstestsuite/browser_tests)
ENV PLAYWRIGHT_BROWSERS_PATH=/home/cmsuser/ms-playwright
RUN --mount=type=cache,target=/home/cmsuser/.cache/pip,uid=2000 \
pip install -c constraints.txt ".[browser-test]" \
&& sudo "$(which playwright)" install-deps chromium \
&& playwright install chromium

RUN <<EOF
#!/bin/bash -ex
sed 's|/cmsuser:your_password_here@localhost:5432/cmsdb"|/postgres@testdb:5432/cmsdbfortesting"|' \
Expand Down
4 changes: 3 additions & 1 deletion cms/server/contest/static/cws_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ CMS.CWSUtils.prototype.display_notification = function(type, timestamp,
// TODO somehow display timestamp, subject and text

var alert = $('<div class="alert alert-block notification">' +
'<a class="close" data-dismiss="alert" href="#">×</a>' +
'<button type="button" class="close" data-dismiss="alert">×</button>' +
'<h4 class="alert-heading"></h4>' +
'</div>');
alert.children("button.close").attr(
"aria-label", $("#translation_close").text().trim());

if (type == "message") {
alert.children("h4").text($("#translation_new_message").text());
Expand Down
5 changes: 4 additions & 1 deletion cms/server/contest/templates/contest.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
{% if handler.get_argument("login_error", "") != "" %}
<div id="notifications" class="notifications">
<div class="alert alert-block alert-error notification">
<a class="close" data-dismiss="alert" href="#">&#xD7;</a>
<button type="button" class="close" data-dismiss="alert" aria-label="{% trans %}Close{% endtrans %}">&#xD7;</button>
<h4 class="alert-heading">{% trans %}Failed to log in.{% endtrans %}</h4>
</div>
</div>
Expand Down Expand Up @@ -136,6 +136,9 @@ <h1>{% trans %}Welcome{% endtrans %}</h1>
<div style="display: none" id="translation_new_answer">
{% trans %}New answer{% endtrans %}
</div>
<div style="display: none" id="translation_close">
{% trans %}Close{% endtrans %}
</div>
<div style="display: none" id="translation_unread">
{% trans %}%d unread{% endtrans %}
</div>
Expand Down
2 changes: 1 addition & 1 deletion cms/server/contest/templates/register.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ <h1>{% trans %}Registration{% endtrans %}</h1>
<div id="username-input" class="control-group">
<label class="control-label" for="username">{% trans %}Username{% endtrans %}</label>
<div class="controls">
<input required minlength="1" maxlength="{{ MAX_INPUT_LENGTH }}" pattern="^[A-Za-z0-9_-]+$" type="text" class="input-xlarge" name="username" id="username">
<input required minlength="1" maxlength="{{ MAX_INPUT_LENGTH }}" pattern="^[A-Za-z0-9_\-]+$" type="text" class="input-xlarge" name="username" id="username">
<span class="help-block"></span>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion cms/server/contest/templates/task_submissions.html
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ <h3>{% trans %}Official submissions{% endtrans %}</h3>

<div class="modal fade hide wide" id="submission_detail">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&#xD7;</button>
<button type="button" class="close" data-dismiss="modal" aria-label="{% trans %}Close{% endtrans %}">&#xD7;</button>
<h3>{% trans %}Submission details{% endtrans %}</h3>
</div>
<div class="modal-body">
Expand Down
2 changes: 1 addition & 1 deletion cms/server/contest/templates/test_interface.html
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ <h2 style="margin: 40px 0 10px">{% trans %}Previous tests{% endtrans %}</h2>

<div class="modal fade hide" id="user_test_detail">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&#xD7;</button>
<button type="button" class="close" data-dismiss="modal" aria-label="{% trans %}Close{% endtrans %}">&#xD7;</button>
<h3>{% trans %}Test details{% endtrans %}</h3>
</div>
<div class="modal-body">
Expand Down
16 changes: 16 additions & 0 deletions cmstestsuite/browser_tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env python3

# Contest Management System - http://cms-dev.github.io/
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
52 changes: 52 additions & 0 deletions cmstestsuite/browser_tests/communication_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3

# Contest Management System - http://cms-dev.github.io/
# Copyright © 2026 Pasit Sangprachathanarak <ouipingpasit@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Browser tests for the communication page."""

from playwright.sync_api import expect

from cmstestsuite.browser_tests import seed


def test_shows_announcements_messages_and_answers(page, base_url, login):
login(seed.READER)
page.get_by_role("link", name="Communication").click()
expect(page.get_by_text(seed.ANNOUNCEMENT_SUBJECT)).to_be_visible()
expect(page.get_by_text(seed.MESSAGE_SUBJECT)).to_be_visible()
expect(page.get_by_text(seed.ANSWERED_QUESTION_SUBJECT).first) \
.to_be_visible()
expect(page.get_by_text(seed.ANSWER_TEXT)).to_be_visible()


def test_ask_a_question(page, base_url, login):
login(seed.ASKER)
page.get_by_role("link", name="Communication").click()
page.locator("#input_subject").fill("Where is the toilet?")
page.locator("#input_text").fill("Asking for a friend.")
page.get_by_role("button", name="Ask question").click()

page.get_by_role("link", name="Communication").click()
expect(page.get_by_text("Where is the toilet?").first).to_be_visible()


def test_reset_clears_the_question_form(page, base_url, login):
login(seed.ASKER)
page.get_by_role("link", name="Communication").click()
page.locator("#input_subject").fill("draft")
page.get_by_role("button", name="Reset").click()
expect(page.locator("#input_subject")).to_have_value("")
161 changes: 161 additions & 0 deletions cmstestsuite/browser_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env python3

# Contest Management System - http://cms-dev.github.io/
# Copyright © 2026 Pasit Sangprachathanarak <ouipingpasit@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Fixtures for the browser tests of the contest web server (CWS).

The tests need the "browser-test" extra (Playwright and its browsers),
a PostgreSQL database whose name ends in "fortesting", and a CMS_CONFIG
pointing to it. They are run with:

pytest cmstestsuite/browser_tests

"""

import os
import subprocess
import sys
import time
import urllib.error
import urllib.request

import pytest

pytest.importorskip(
"playwright", reason="install the 'browser-test' extra to run these tests")

from cms import config as cms_config # noqa: E402
from cmstestsuite.browser_tests import seed # noqa: E402


def pytest_collection_modifyitems(items):
here = os.path.dirname(__file__)
for item in items:
if str(item.fspath).startswith(here):
item.add_marker(pytest.mark.browser)


def pytest_configure(config):
config.addinivalue_line(
"markers", "browser: drives a real browser against a running CWS")
config.addinivalue_line(
"markers",
"allow_browser_errors: do not fail on console errors, page errors "
"or failed static assets")


@pytest.fixture(scope="session")
def seeded():
"""Recreate the test database and fill it with the fixtures."""
return seed.seed_database()


@pytest.fixture(scope="session")
def base_url(seeded):
"""Start a real CWS on the seeded contest, and return its URL.

Only the CWS runs: the seeded submissions are already evaluated, so
no worker or evaluation service is needed.

"""
port = cms_config.contest_web_server.listen_port[0]
url = "http://127.0.0.1:%d/" % port
executable = os.path.join(sys.prefix, "bin", "cmsContestWebServer")
verbose = os.environ.get("CMS_BROWSER_TEST_VERBOSE")
proc = subprocess.Popen(
[executable, "0", "-c", str(seeded.contest_id)],
stdout=None if verbose else subprocess.DEVNULL,
stderr=None if verbose else subprocess.STDOUT)
try:
deadline = time.monotonic() + 60
while True:
if proc.poll() is not None:
raise RuntimeError(
"ContestWebServer exited early with code %s "
"(set CMS_BROWSER_TEST_VERBOSE=1 to see its output)"
% proc.returncode)
try:
urllib.request.urlopen(url, timeout=2).close()
break
except (urllib.error.URLError, OSError):
if time.monotonic() > deadline:
raise RuntimeError("ContestWebServer did not come up")
time.sleep(0.3)
yield url
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"viewport": {"width": 1280, "height": 900},
"locale": "en-US",
}


# Console messages that are known to be noise rather than regressions.
# Keep this list short and explain each entry.
ALLOWED_CONSOLE_ERRORS: list[str] = []


@pytest.fixture(autouse=True)
def fail_on_browser_errors(request, page):
"""Fail a test on console errors, page errors or broken static files.

This catches broken JavaScript and missing CSS/JS assets, which are
the typical symptom of a front-end framework migration gone wrong.

"""
problems: list[str] = []

def on_console(msg):
if msg.type == "error" and not any(
a in msg.text for a in ALLOWED_CONSOLE_ERRORS):
problems.append("console error: %s" % msg.text)

def on_page_error(exc):
problems.append("uncaught exception: %s" % exc)

def on_response(response):
if "/static/" in response.url and response.status >= 400:
problems.append(
"static asset %s returned %d" % (response.url, response.status))

page.on("console", on_console)
page.on("pageerror", on_page_error)
page.on("response", on_response)
yield
if request.node.get_closest_marker("allow_browser_errors") is None:
assert not problems, "\n".join(problems)


@pytest.fixture
def login(page, base_url, seeded):
"""Return a function logging a seeded user in to the contest."""
def do_login(username: str):
page.goto(base_url)
page.get_by_label("Username").fill(username)
page.get_by_label("Password").fill(seed.PASSWORDS[username])
page.get_by_role("button", name="Login").click()
page.get_by_role("button", name="Logout").wait_for()
return do_login
65 changes: 65 additions & 0 deletions cmstestsuite/browser_tests/downloads_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3

# Contest Management System - http://cms-dev.github.io/
# Copyright © 2026 Pasit Sangprachathanarak <ouipingpasit@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Browser tests for the dropdown menu to download a submission made of
several files, and for the table in the details dialog."""

from playwright.sync_api import expect

from cmstestsuite.browser_tests import seed


def open_pair_submissions(page, base_url):
page.goto("%stasks/%s/submissions" % (base_url, seed.PAIR_TASK_NAME))


def test_multi_file_download_menu_opens_and_closes(page, base_url, login):
login(seed.READER)
open_pair_submissions(page, base_url)
first, second = seed.PAIR_FILES
item = page.get_by_role("link", name=first)
expect(item).to_be_hidden()

page.get_by_text("Download", exact=True).first.click()
expect(item).to_be_visible()
expect(page.get_by_role("link", name=second)).to_be_visible()

# Clicking elsewhere closes the menu.
page.get_by_role("heading").first.click()
expect(item).to_be_hidden()


def test_multi_file_download_menu_downloads_a_file(page, base_url, login):
login(seed.READER)
open_pair_submissions(page, base_url)
page.get_by_text("Download", exact=True).first.click()
with page.expect_download() as info:
page.get_by_role("link", name=seed.PAIR_FILES[1]).click()
assert info.value.suggested_filename == seed.PAIR_FILES[1]


def test_details_dialog_lists_testcases_in_a_table(page, base_url, login):
login(seed.READER)
page.goto("%stasks/%s/submissions" % (base_url, seed.TASK_NAME)) # noqa: UP031
page.get_by_text("details", exact=True).first.click()
dialog = page.locator("#submission_detail")
table = dialog.get_by_role("table").filter(has_text="Correct")
expect(table).to_be_visible()
expect(dialog.get_by_role("columnheader", name="Outcome")).to_be_visible()
expect(dialog.get_by_role("cell", name="Correct", exact=True)) \
.to_be_visible()
Loading
Loading