From bd0a756891e4245aead68423fb60d754d4ea4982 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 14 Oct 2026 20:16:21 +0300 Subject: [PATCH] Add browser smoke tests for the page --- .github/workflows/_checks.yml | 15 +++++ AGENTS.md | 6 +- justfile | 5 ++ pyproject.toml | 8 ++- tests/test_browser.py | 114 ++++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 tests/test_browser.py diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 74ec122..650931f 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -57,6 +57,21 @@ jobs: - run: uv pip install --no-deps -e . - run: uv run --no-sync pytest + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 + - uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install 3.13 + - run: uv python pin 3.13 + - run: just install + - run: uv run --no-sync playwright install --with-deps chromium + - run: just test-smoke + pytest: runs-on: ubuntu-latest strategy: diff --git a/AGENTS.md b/AGENTS.md index f186288..0733acf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,10 @@ issue title. `_use_case.py` drives one run; `_gitlab.py` and `_jira.py` are the only modules that speak HTTP; `_rows.py` is pure and turns the range plus merge requests into rows; `_candidates.py` is pure and turns a service's rows into the tags a -release could ship. `_static/` is the site `collect` copies next to `report.json`: `index.html` with vendored Alpine.js, -which has no tests, so check it in a browser against a report from the CLI tests. Tests +release could ship. `_static/` is the site `collect` copies next to `report.json`: `index.html` with vendored Alpine.js. +Besides the translation checks in `tests/test_page.py`, it has browser smoke tests in `tests/test_browser.py`, marked +`smoke` and run by `just test-smoke`, not `just test`; still check a page change in a browser against a report from +the CLI tests. Tests mock GitLab and Jira only with respx routes (pytest-httpx2): the `gitlab` and `jira` fixtures in `tests/conftest.py` declare one named static route per call of the scenario in `tests/payloads.py`. A test changes a response by re-mocking a named route; add no fakes, callbacks, or stubs. diff --git a/justfile b/justfile index fd69108..4c3ce6f 100644 --- a/justfile +++ b/justfile @@ -38,6 +38,11 @@ test *args: test-ci: uv run --no-sync pytest --cov=release_scope --cov-report term-missing --cov-report xml +# Browser smoke test of the page against a report from the CLI scenario; installs Chromium on first run. +test-smoke *args: + uv run --no-sync playwright install chromium + uv run --no-sync pytest -m smoke {{ args }} + # Branch-coverage run (diagnostic; line coverage is the enforced gate, not branch). test-branch: uv run --no-sync pytest --cov=release_scope --cov-branch diff --git a/pyproject.toml b/pyproject.toml index 9b5dbb7..0c30870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev = [ "respx>=0.23.1", "httpx>=0.28.1", "httpcore2>=2", + "pytest-playwright>=0.10", ] lint = [ "ruff", @@ -93,10 +94,13 @@ isort.no-lines-before = ["standard-library", "local-folder"] ] [tool.pytest.ini_options] -addopts = "" +addopts = "-m 'not smoke'" testpaths = ["tests"] # pytest-httpx2 defines this marker in a hook its package never exports, so pytest does not see it. -markers = ["httpx2: configure the httpx2_mock fixture"] +markers = [ + "httpx2: configure the httpx2_mock fixture", + "smoke: browser smoke test of the page, run by `just test-smoke`", +] [tool.coverage.report] fail_under = 100 diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..9d1c9ba --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,114 @@ +import collections.abc +import functools +import http.server +import pathlib +import re +import threading +import typing + +import pytest +import respx +from playwright.sync_api import ConsoleMessage, Error, Locator, Page, expect +from typer.testing import CliRunner + +from release_scope import ioc +from release_scope.__main__ import MAIN_APP +from tests.payloads import ENDPOINT, JIRA_ENDPOINT + + +pytestmark = pytest.mark.smoke + + +class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002, ANN401 + pass + + +@pytest.fixture +def site(jira: respx.Router, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> collections.abc.Iterator[str]: + monkeypatch.setenv("GITLAB_TOKEN", "glpat-test") + monkeypatch.setenv("RELEASE_SCOPE_GITLAB__ENDPOINT", ENDPOINT) + monkeypatch.setenv("RELEASE_SCOPE_ENVIRONMENTS", '["preview"]') + monkeypatch.setenv("RELEASE_SCOPE_JIRA_ENDPOINT", JIRA_ENDPOINT) + monkeypatch.setenv("JIRA_TOKEN", "jira-pat") + output: typing.Final = tmp_path / "public" + with ioc.container: + result = CliRunner().invoke(MAIN_APP, ["collect", "-g", "team", "-o", str(output)]) + assert result.exit_code == 0, result.output + assert jira["jira_search"].called + server: typing.Final = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), functools.partial(_QuietHandler, directory=str(output)) + ) + thread: typing.Final = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_port}/" + server.shutdown() + server.server_close() + + +@pytest.fixture +def errors(page: Page) -> list[str]: + collected: list[str] = [] + + def console(message: ConsoleMessage) -> None: + if message.type == "error": + collected.append(message.text) + + def crash(error: Error) -> None: + collected.append(str(error)) + + page.on("console", console) + page.on("pageerror", crash) + return collected + + +def _issue(page: Page, key: str) -> Locator: + return page.locator(".issue-row", has=page.locator("code", has_text=key)).get_by_role("checkbox") + + +def test_page_lists_the_services_without_errors(page: Page, site: str, errors: list[str]) -> None: + page.goto(site) + + expect(page.locator("details.service .name")).to_have_text(["team/svc"]) + expect(page.locator(".issue-row code")).to_have_text(["SHOP-9", "SHOP-12"]) + assert errors == [] + + +def test_choosing_an_issue_picks_its_release_tag_and_survives_a_reload( + page: Page, site: str, errors: list[str] +) -> None: + page.goto(site) + + _issue(page, "SHOP-12").check() + + chip: typing.Final = page.locator("details.service .chip > span") + expect(chip).to_have_text("1.2.0") + expect(page.locator(".plan-line")).to_contain_text("for SHOP-12") + expect(page).to_have_url(re.compile(r"#issues=SHOP-12&team/svc=1\.2\.0$")) + page.reload() + expect(chip).to_have_text("1.2.0") + expect(_issue(page, "SHOP-12")).to_be_checked() + assert errors == [] + + +def test_picking_a_tag_by_hand_shows_its_chip_and_closes_the_service(page: Page, site: str, errors: list[str]) -> None: + page.goto(site) + + page.locator("details.service summary").click() + page.locator(".tag", has_text="1.1.0").get_by_role("button").click() + + expect(page.locator("details.service .chip > span")).to_have_text("1.1.0") + expect(page.locator("details.service")).not_to_have_attribute("open", re.compile(".*")) + expect(page).to_have_url(re.compile(r"#team/svc=1\.1\.0$")) + assert errors == [] + + +def test_russian_page_has_no_untranslated_placeholders(page: Page, site: str, errors: list[str]) -> None: + page.goto(site) + _issue(page, "SHOP-12").check() + + page.get_by_role("combobox").first.select_option("ru") + + expect(page.locator("h1")).to_have_text("Состав релиза") + expect(page.locator("main")).not_to_contain_text(re.compile(r"\{\w+\}")) + assert errors == []