Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dev = [
"respx>=0.23.1",
"httpx>=0.28.1",
"httpcore2>=2",
"pytest-playwright>=0.10",
]
lint = [
"ruff",
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions tests/test_browser.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading