diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 87833fb5f..6acd635ba 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,7 +1,5 @@ name: E2E Tests - on: - pull_request: push: branches: @@ -15,7 +13,38 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v7 + with: + # full history so the PR base commit is available to diff against + fetch-depth: 0 + + - name: Select tests + id: select + # Per-episode tests run unless EVERY changed file is known not to affect episode pages. + # Anything not listed in SAFE (Hugo version, config.toml, base layouts, CSS/JS, new paths...) runs them. + run: | + markers="not periodic" + if [ "${{ github.event_name }}" = "pull_request" ]; then + SAFE='^(docs/|archetypes/|scripts/|data/picks/|[^/]+\.md$|render\.yaml$' + SAFE+='|content/' + SAFE+='|themes/jb/layouts/(archive|calendar|community|contact|live|picks|sponsors|tags|taxonomy|people|show|section)/' + SAFE+='|themes/jb/layouts/(index|404)\.html$|themes/jb/layouts/partials/(live|community|show)/' + SAFE+='|themes/jb/assets/js/(live|jb-live)\.js$' + SAFE+='|test/|\.github/)' + # carve-outs from SAFE that do feed episode pages + UNSAFE='^(content/(show|people|sponsors|membership)/' + UNSAFE+='|test/(conftest\.py|e2e/test_episode\.py|e2e/episode_data\.py|Pipfile|Pipfile\.lock|docker-compose\.yml)$' + UNSAFE+='|\.github/workflows/e2e\.yml$)' + changed=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }}) + affecting=$(printf '%s\n' "$changed" | grep -v '^$' | grep -E "$UNSAFE" ; printf '%s\n' "$changed" | grep -v '^$' | grep -vE "$SAFE") + if [ -z "$affecting" ]; then + markers="not periodic and not episodes" + else + echo "Running episode tests; changed files that can affect episode pages:" + printf '%s\n' "$affecting" | sort -u | head -20 + fi + fi + echo "markers=$markers" >> "$GITHUB_OUTPUT" # using docker compose, so we can re-use local Dockerfiles - name: starting up containers @@ -30,17 +59,20 @@ jobs: - name: Run your tests working-directory: ./test/ # https://github.com/actions/runner/issues/241#issuecomment-577360161 - run: script -e -c "docker compose exec testing pytest -m 'not periodic' --base-url http://jb-com --junitxml report.xml" + run: script -e -c "docker compose exec testing pytest -m '${{ steps.select.outputs.markers }}' --base-url http://jb-com --junitxml report.xml" - name: Save screenshots - uses: actions/upload-artifact@v4 + # screenshots are only taken on failure, so this has to run when the test step fails + if: always() + uses: actions/upload-artifact@v7 with: name: screenshots path: screenshots/ overwrite: true + if-no-files-found: ignore - name: Publish Test Report - uses: dorny/test-reporter@v1 + uses: dorny/test-reporter@v3 # TODO: potential solution in the future: https://stackoverflow.com/a/70448851 if: always() && github.event_name != 'pull_request' with: diff --git a/.github/workflows/periodic_test.yml b/.github/workflows/periodic_test.yml index e396b8105..10f7a7318 100644 --- a/.github/workflows/periodic_test.yml +++ b/.github/workflows/periodic_test.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v7 # using docker compose, so we can re-use local Dockerfiles - name: starting up containers @@ -28,7 +28,7 @@ jobs: run: script -e -c "docker compose exec testing pytest -m periodic --base-url http://jb-com --junitxml report.xml" - name: Publish Test Report - uses: dorny/test-reporter@v1 + uses: dorny/test-reporter@v3 with: name: Periodic Tests path: './report.xml' diff --git a/Makefile b/Makefile index 005f289ca..c6ed48143 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ dev: dev-build ## Run the development hugo container dev-metrics: dev-build ## Run the development hugo container with metrics docker run --rm -it -v "$${PWD}":/app -w /app -p $${HOST_IP:-127.0.0.1}:1313:1313 jb_hugo:latest serve -D --bind 0.0.0.0 --templateMetrics --templateMetricsHints +dev-tests: dev-build ## Run the development hugo container with every page pre-rendered (for `make tests` with parallel workers) + docker run --rm -it -v "$${PWD}":/app -w /app -p $${HOST_IP:-127.0.0.1}:1313:1313 jb_hugo:latest serve -D --bind 0.0.0.0 --disableFastRender + deploy-prod: ## Deploy everything to production docker build -t jb-jbcom . docker compose -f ~/docker-compose.yml up -d --force-recreate jb-jbcom diff --git a/config/_default/params.toml b/config/_default/params.toml index c364d27a5..c148aa4bc 100644 --- a/config/_default/params.toml +++ b/config/_default/params.toml @@ -1,7 +1,7 @@ keywords = "Linux, FreeBSD, howto, Ubuntu, Arch, tech news, HD, app review, Systems, Network, Sysadmin, netsec, Politics, development, podcast, Linux Action Show, TechSNAP, SciByte, FauxShow, Coder Radio, LIVE stream, Bitcoin, PlanB, BSDNow" description = "Independent coverage of Linux, BSD, Open Source, System Administration, Information Security, News, Software Development and Discussion." author = "Jupiter Broadcasting" -ogimage = "/images/jupiter-broadcasting.png" # Open Graph default image +ogimage = "/images/logo.png" # Open Graph default image # itunes feed info feed.copyright = "Copyright 2022 Jupiter Broadcasting" #do not use markdown in this field; it is used in the feed diff --git a/pytest.ini b/pytest.ini index d8bb23af2..fe0ee50ed 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,8 +2,9 @@ python_files = test_* python_classes = *Tests python_functions = test_* -addopts = -ra -v +addopts = -ra -v -n auto --color=yes --browser chromium --browser firefox --browser webkit --screenshot only-on-failure --output screenshots testpaths = test markers = dev: "the current test(s) you're working on for developing" periodic: "tests that need to run on a schedule instead of running when a PR is made" + episodes: "per-episode tests over the content sample; CI runs them only on PRs that touch episode content or templates" diff --git a/test/conftest.py b/test/conftest.py index 795c8f1a5..4083288db 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,11 +1,56 @@ +import re from pathlib import Path -from typing import Dict, Generator, List, Tuple, Callable -from pytest import fixture +from typing import Dict, Generator, List, Tuple +from pytest import fixture, hookimpl from _pytest.fixtures import SubRequest from pytest_base_url.plugin import base_url -from playwright.sync_api import Playwright, APIRequestContext, Route, Page, Browser -import json +from playwright.sync_api import Playwright, APIRequestContext, Page, Browser + +# device -> the engine it really ships with (all iOS browsers are WebKit), so each device +# only runs on that browser instead of all three +MOBILE_DEVICES = { + # phones: narrowest, current iPhone width, newest Pixel in Playwright 1.50 + "Galaxy S9+": "chromium", + "iPhone 15": "webkit", + "Pixel 7": "chromium", + # tablets: 768 is Bulma's last mobile px, 834 is Bulma tablet, + # 1194 is desktop layout but still under $navbar-breakpoint (1220px) so the burger shows + "iPad Mini": "webkit", + "iPad Pro 11": "webkit", + "iPad Pro 11 landscape": "webkit", +} + +@hookimpl(wrapper=True) +def pytest_runtest_makereport(item, call): + # exposes the call-phase result to fixtures, so mobile_device_tuple can screenshot on failure + report = yield + if report.when == "call": + item.rep_call = report + return report + + +def pytest_collection_modifyitems(config, items): + """ + Deselect (rather than skip) browser copies of tests marked only_browser/skip_browser, + so the summary doesn't report hundreds of skips that aren't real + """ + keep, drop = [], [] + for item in items: + browser = getattr(item, "callspec", None) and item.callspec.params.get("browser_name") + only = {b for m in item.iter_markers("only_browser") for b in m.args} + device = getattr(item, "callspec", None) and item.callspec.params.get("mobile_device_tuple") + if device: + only = {MOBILE_DEVICES[device]} + skip = {b for m in item.iter_markers("skip_browser") for b in m.args} + if browser and ((only and browser not in only) or browser in skip): + drop.append(item) + else: + keep.append(item) + if drop: + config.hook.pytest_deselected(items=drop) + items[:] = keep + @fixture def get_test_dir() -> Path: @@ -28,14 +73,8 @@ def screenshot_dir() -> Path: # also should be able to view them here: # https://github.com/microsoft/playwright/blob/v1.25.2/packages/playwright-core/src/server/deviceDescriptorsSource.json @fixture( - # doing session so it doesn't have to be re-created for each usage - scope="session", - # some example phone sizes to use - params=[ - "iPhone 13", - "Pixel 5", - "Galaxy S9+", - ] + # function scope so menu state from one test can't leak into the next + params=list(MOBILE_DEVICES), ) def mobile_device_tuple( # how to access the params @@ -49,17 +88,19 @@ def mobile_device_tuple( base_url: base_url, ) -> Tuple[Page,str]: - # based on here: https://playwright.dev/python/docs/emulation#devices context = browser.new_context( base_url=base_url, - # based on this info: https://playwright.dev/python/docs/emulation#devices **playwright.devices[request.param] ) + page = context.new_page() try: # essentially a "return", but used with generators - yield (context.new_page(), request.param) - except Exception as excpt: - raise excpt + yield (page, request.param) + # pytest-playwright's --screenshot only covers its own page fixture, not contexts made here + report = getattr(request.node, "rep_call", None) + if report and report.failed: + slug = re.sub(r"[^\w.-]+", "-", request.node.nodeid).strip("-") + page.screenshot(path=Path(request.config.getoption("--output")) / slug / "screenshot.png") finally: # supposed to get rid of coookies/other stored info after generator is done # https://playwright.dev/python/docs/api/class-apirequestcontext#api-request-context-dispose @@ -161,30 +202,3 @@ def expect_nav_items() -> List[Dict[str,str]]: # failing on tests here: https://github.com/JupiterBroadcasting/jupiterbroadcasting.com/runs/8254156209?check_suite_focus=true#step:9:26 {'title': 'Contact', 'href': '/contact/'}, ] - -@fixture -def set_live() -> Callable: - return _replace_live_event - -@staticmethod -def _replace_live_event(page: Page) -> None: - def handle_route(route: Route) -> None: - # fetch original response - response = page.request.fetch(route.request) - json_data = response.json() - - # override response values - if json_data.get('data'): - json_data['data'][0]['isLive'] = True - json_data['total'] = 1 - - # setting live event - route.fulfill( - # Pass all fields from the response - response=response, - body=json.dumps(json_data), - ) - page.route( - "https://jupiter.tube/api/v1/video-channels/live/**", - handle_route - ) \ No newline at end of file diff --git a/test/e2e/episode_data.py b/test/e2e/episode_data.py new file mode 100644 index 000000000..0d79184e2 --- /dev/null +++ b/test/e2e/episode_data.py @@ -0,0 +1,81 @@ +import json +import re +from datetime import datetime, timezone +from dataclasses import dataclass, field +from pathlib import Path + +CONTENT: Path = Path(__file__).parents[2] / "content" +CONTENT_SHOW: Path = CONTENT / "show" +META_FILES: set[str] = {"_index.md", "subscribe.md"} +ACTIVE_SAMPLE: int = 5 # the scraper can rewrite an active show's last 5 episodes +ARCHIVED_SAMPLE: int = 2 + +EXTRA_EPISODES: list[tuple[str, str]] = [ + ("linux-unplugged", "434"), # 3 hosts and 3 guests + ("coder-radio", "343"), # no sponsors + ("coder-radio", "443"), # tag with an apostrophe (issue #534) +] + + +@dataclass(frozen=True) +class Episode: + show: str + number: str + url: str + data: dict = field(compare=False, hash=False, repr=False) + + +def load_episode(path: Path) -> Episode: + data, _ = json.JSONDecoder().raw_decode(path.read_text().lstrip()) + show = path.parent.name + number = str(data["episode"]) + return Episode(show=show, number=number, url=f"/show/{show}/{str(data['slug']).lower()}/", data=data) + + +def episodes_by_date(show_dir: Path) -> list[Episode]: + # filenames aren't chronological (e.g. the-launch T-0.md sorts after 0097.md) + episodes = [load_episode(p) for p in show_dir.glob("*.md") if p.name not in META_FILES] + return sorted(episodes, key=lambda e: _published(e.data["date"])) + + +def _published(date: str) -> datetime: + # Hugo reads dates without an offset as UTC (no timeZone set in config.toml) + parsed = datetime.fromisoformat(date) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def is_active(show_dir: Path) -> bool: + return re.search(r"^\s*active\s*=\s*true\b", (show_dir / "_index.md").read_text(), re.M) is not None + + +def sample_episodes() -> list[Episode]: + """ + Oldest + newest episodes of every show (ACTIVE_SAMPLE each for active shows, ARCHIVED_SAMPLE for archived), + plus EXTRA_EPISODES (known edge cases) + """ + selected: list[Episode] = [] + for show_dir in sorted(p for p in CONTENT_SHOW.iterdir() if p.is_dir()): + by_date = episodes_by_date(show_dir) + n = ACTIVE_SAMPLE if is_active(show_dir) else ARCHIVED_SAMPLE + selected += by_date[:n] + by_date[-n:] + selected += [load_episode(CONTENT_SHOW / show / f"{int(number):04d}.md") for show, number in EXTRA_EPISODES] + + episodes: dict[str, Episode] = {} + for episode in selected: + episodes.setdefault(episode.url, episode) + return list(episodes.values()) + + +def person_type(username: str) -> str | None: + """ + "host"/"guest" from content/people/.md (YAML front matter), or None if there's no people page + """ + path = CONTENT / "people" / f"{username}.md" + if not path.exists(): + return None + match = re.search(r"^type: *[\"']?(\w+)", path.read_text(), re.M) + return match.group(1) if match else None + + +def sponsor_exists(shortname: str) -> bool: + return (CONTENT / "sponsors" / f"{shortname}.md").exists() diff --git a/test/e2e/test_404.py b/test/e2e/test_404.py new file mode 100644 index 000000000..27707c242 --- /dev/null +++ b/test/e2e/test_404.py @@ -0,0 +1,24 @@ +from uuid import uuid4 + +from pytest import fixture +from playwright.sync_api import Page, expect + + +@fixture(autouse=True) +def setup(page: Page): + """ + Code here will run for every test in this file + """ + response = page.goto(f"/this-page-does-not-exist-{uuid4()}/") + assert response.status == 404 + + +def test_404_contents(page: Page): + """ + Ensure text "Page Not Found" and unicorn image is present on the 404 page + """ + title = page.locator(".title.has-text-centered") + expect(title).to_contain_text("Page Not Found") + + unicorn = page.locator("#img-404") + expect(unicorn).to_be_visible() diff --git a/test/e2e/test_calendar.py b/test/e2e/test_calendar.py new file mode 100644 index 000000000..8be7f4bed --- /dev/null +++ b/test/e2e/test_calendar.py @@ -0,0 +1,48 @@ +from pytest import fixture, mark +from playwright.sync_api import Page, expect + + +@fixture(autouse=True) +def setup(page: Page): + """ + Code here will run for every test in this file + """ + # Keep Google Calendar out of the test: only the iframe src (our markup) is asserted. + page.route("**/calendar/embed*", lambda route: route.abort()) + page.goto("/calendar") + + +@mark.browser_context_args(timezone_id="Asia/Tokyo") +def test_calendar_default_timezone(page: Page): + """ + Ensure the calendar iframe defaults to the browser's timezone + """ + iframe = page.locator("iframe") + calendar_base = iframe.get_attribute("data-src") + expect(iframe).to_have_attribute("src", calendar_base + "&ctz=Asia%2FTokyo") + + +def test_calendar_timezone(page: Page): + """ + Ensure choosing a timezone in the dropdown updates the calendar iframe src + """ + iframe = page.locator("iframe") + calendar_base = iframe.get_attribute("data-src") + + page.locator("select#ctz").select_option("Europe/London") + expect(iframe).to_have_attribute("src", calendar_base + "&ctz=Europe/London") + + page.locator("select#ctz").select_option("Pacific/Auckland") + expect(iframe).to_have_attribute("src", calendar_base + "&ctz=Pacific/Auckland") + + +def test_calendar_timezone_placeholder(page: Page): + """ + Ensure re-selecting the placeholder option leaves the current timezone in place + """ + iframe = page.locator("iframe") + calendar_base = iframe.get_attribute("data-src") + + page.locator("select#ctz").select_option("Europe/London") + page.locator("select#ctz").select_option("none") + expect(iframe).to_have_attribute("src", calendar_base + "&ctz=Europe/London") diff --git a/test/e2e/test_community.py b/test/e2e/test_community.py new file mode 100644 index 000000000..8598f1308 --- /dev/null +++ b/test/e2e/test_community.py @@ -0,0 +1,163 @@ +import re +from dataclasses import dataclass +from pathlib import Path + +from pytest import mark, param +from playwright.sync_api import APIRequestContext, Locator, Page, expect + +from episode_data import CONTENT + +REPO_ROOT = CONTENT.parent +CONFIG = (REPO_ROOT / "config.toml").read_text() +GITHUB_REPO = re.search( + r'^github_repo\s*=\s*"(.*)"', (REPO_ROOT / "config/_default/params.toml").read_text(), re.M +).group(1) + + +@dataclass(frozen=True) +class CommunityPage: + slug: str + title: str + link: str # direct_link, or the page's own path + draft: bool + + +def _toml_field(text: str, key: str) -> str | None: + match = re.search(rf'^{key}\s*=\s*"?(.*?)"?\s*$', text, re.M) + return match.group(1) if match else None + + +def _load_community(path: Path) -> CommunityPage: + text = path.read_text() + return CommunityPage( + slug=path.stem, + title=_toml_field(text, "title"), + link=_toml_field(text, "direct_link") or f"/community/{path.stem}/", + draft=_toml_field(text, "draft") == "true", + ) + + +COMMUNITY = [_load_community(p) for p in sorted((CONTENT / "community").glob("*.md")) if p.name != "_index.md"] +# the dev server renders drafts (-D) but production doesn't, so drafts may or may not be listed +REQUIRED_LINKS = {c.link for c in COMMUNITY if not c.draft} +ALLOWED_LINKS = {c.link for c in COMMUNITY} + + +def _hrefs(links: Locator) -> set[str]: + return {re.sub(r"^https?://(localhost|127\.0\.0\.1|jb-com)(:\d+)?", "", links.nth(i).get_attribute("href")) for i in range(links.count())} + + +def _menu(name: str) -> list[tuple[str, str]]: + blocks = re.findall(rf"^\[\[Menu\.{name}\]\]\n((?:[ \t]+.*\n)+)", CONFIG, re.M) + return [(_toml_field(b.replace(" ", ""), "name"), _toml_field(b.replace(" ", ""), "url")) for b in blocks] + + +@mark.only_browser("chromium") +def test_community_index(page: Page): + """ + /community/ has one card per community page, linking to its direct_link or its own page + """ + page.goto("/community/") + rendered = _hrefs(page.locator(".section .columns a")) + assert REQUIRED_LINKS <= rendered <= ALLOWED_LINKS, f"unexpected cards: {rendered - ALLOWED_LINKS}, missing: {REQUIRED_LINKS - rendered}" + for community in COMMUNITY: + if community.link in rendered: + expect(page.locator(".card-header-title", has_text=re.compile(rf"^\s*{re.escape(community.title)}\s*$"))).to_have_count(1) + + +@mark.parametrize( + "community", + [param(c, id=c.slug) for c in COMMUNITY if c.link.startswith("/") and not c.draft], +) +def test_community_subpage(api_request_context: APIRequestContext, community: CommunityPage): + """ + Community pages without a direct_link have their own page + """ + response = api_request_context.get(community.link) + assert response.status == 200, f"{community.link} returned {response.status}" + title = re.search(r"]*>(.*?)", response.text()) + assert title and title.group(1).startswith(f"{community.title} | "), f" is {title and title.group(1)!r}" + + +@mark.only_browser("chromium") +def test_footer_community_links(page: Page): + """ + The footer's first link column lists the same community links as /community/ + """ + page.goto("/") + column = page.locator("footer .columns").first.locator(".column").nth(1) + rendered = _hrefs(column.locator("a")) + assert REQUIRED_LINKS <= rendered <= ALLOWED_LINKS, f"unexpected: {rendered - ALLOWED_LINKS}, missing: {REQUIRED_LINKS - rendered}" + + +@mark.only_browser("chromium") +@mark.parametrize( + "path, edit_url", + [ + param("/", f"{GITHUB_REPO}/blob/main/themes/jb/layouts/index.html", id="home"), + param("/membership/", f"{GITHUB_REPO}/tree/main/content/membership/index.md", id="membership"), + param("/community/irc/", f"{GITHUB_REPO}/tree/main/content/community/irc.md", id="community-irc"), + ], +) +def test_footer_involved_links(page: Page, path: str, edit_url: str): + """ + The footer's last column has the FooterRight menu from config.toml and an edit link to the page's source + """ + page.goto(path) + column = page.locator("footer .columns").first.locator(".column").last + for name, url in _menu("FooterRight"): + expect(column.get_by_role("link", name=name, exact=True)).to_have_attribute("href", url) + expect(column.get_by_role("link", name="Edit this page on GitHub")).to_have_attribute("href", edit_url) + + +@dataclass(frozen=True) +class Pick: + title: str + url: str + episodes: tuple[str, ...] # /show/<slug>/<episode> + + +def _load_pick(path: Path) -> Pick: + # flat YAML apart from the shows list; the test image has no YAML parser + text = path.read_text() + shows = re.split(r"^- ", text.split("\nshows:\n", 1)[1], flags=re.M)[1:] if "\nshows:\n" in text else [] + episodes = [] + for entry in shows: + fields = dict(re.findall(r"^\s*(episode|slug):\s*['\"]?([^'\"\n]+)", entry, re.M)) + if fields.keys() >= {"episode", "slug"}: + episodes.append(f"/show/{fields['slug']}/{fields['episode']}") + return Pick( + title=re.search(r"^title:\s*['\"]?(.+?)['\"]?\s*$", text, re.M).group(1), + url=re.search(r"^url:\s*(\S+)", text, re.M).group(1), + episodes=tuple(episodes), + ) + + +PICKS = [_load_pick(p) for p in sorted((REPO_ROOT / "data/picks").glob("*.yaml"))] + + +@mark.only_browser("chromium") +def test_picks_page(page: Page): + """ + /community/picks/ has one card per data/picks file, with its link and one badge per episode it was picked on + """ + page.goto("/community/picks/") + cards = page.locator(".pick.card") + expect(cards).to_have_count(len(PICKS)) + rendered = { + (card["url"], tuple(card["episodes"])) + for card in cards.evaluate_all( + "cards => cards.map(c => ({url: c.querySelector('h4 a').getAttribute('href')," + " episodes: [...c.querySelectorAll('a.control')].map(a => a.getAttribute('href'))}))" + ) + } + assert rendered == {(p.url, p.episodes) for p in PICKS} + + +def test_picks_episode_links(api_request_context: APIRequestContext): + """ + Every episode a pick links to exists + """ + links = sorted({e for p in PICKS for e in p.episodes}) + broken = [link for link in links if api_request_context.get(f"{link}/").status != 200] + assert not broken, f"pick episode links that 404: {broken}" diff --git a/test/e2e/test_contact.py b/test/e2e/test_contact.py index 0986e559a..0de07aa77 100644 --- a/test/e2e/test_contact.py +++ b/test/e2e/test_contact.py @@ -1,26 +1,29 @@ +import re from pathlib import Path -from playwright.sync_api import Page, FrameLocator, Locator - -""" -Take a screenshot of the contact page for manual review -""" -def test_contact_screenshot(page: Page, screenshot_dir: Path): - page.goto("/contact", wait_until="networkidle") - page.screenshot(path=f"{screenshot_dir}/contact.png", full_page=True) - -""" -This test ensures that the "Submit" button at the bottom of the contact form is always visible to the user, even if they forget the fill in the fields. This -test also saves a screenshot of the contact page after the form validation errors have been generated for manual review if needed. - -Steps: - 1. Go to contact page - 2. Click "Submit" button without filling out any data - 3. Get the height of the iframe (defined as an html attribute) and adds it to the distance from the top of the screen to the top of the iframe - 4. Measure the height of the top of the screen to the bottom of the "Submit" button - 5. Pass if the distance from the bottom of the iframe is greater than the distance to the bottom of the button in the iframe (ie. the button can be seen) -""" -def test_submit_button_visible(page: Page, screenshot_dir: Path): +from pytest import fixture +from playwright.sync_api import Page, FrameLocator, Locator, expect + + +@fixture(autouse=True) +def setup(page: Page): + """ + Code here will run for every test in this file + """ page.goto("/contact") + + +def test_contect_submit_button_visible(page: Page): + """ + This test ensures that the "Submit" button at the bottom of the contact form is always visible to the user, even if they forget the fill in the fields. This + test also saves a screenshot of the contact page after the form validation errors have been generated for manual review if needed. + + Steps: + 1. Go to contact page + 2. Click "Submit" button without filling out any data + 3. Get the height of the iframe (defined as an html attribute) and adds it to the distance from the top of the screen to the top of the iframe + 4. Measure the height of the top of the screen to the bottom of the "Submit" button + 5. Pass if the distance from the bottom of the iframe is greater than the distance to the bottom of the button in the iframe (ie. the button can be seen) + """ contact_form: FrameLocator = page.frame_locator('#contact-frame') submit_button: Locator = contact_form.locator("#saveForm") @@ -30,14 +33,25 @@ def test_submit_button_visible(page: Page, screenshot_dir: Path): page.locator("h1").scroll_into_view_if_needed() # This test assumes the iframe will always be rendered, if the test fails here, the iframe is missing - frame_height: float = float(page.locator("#contact-frame").get_attribute("height")) + page.locator(selector="#contact-frame").bounding_box().get("y") + frame_height: float = float(page.locator("#contact-frame").get_attribute("height")) + \ + page.locator(selector="#contact-frame").bounding_box().get("y") - # This is just a precautionary measure to ensure the submit button is on the screen it'll return None if it's not, and since here are more places that - # use the value from the bounding_box's return ensuring it's not None + # This is just a precautionary measure to ensure the submit button is on the screen it'll return None if it's not, + # and since here are more places use the value from the bounding_box's return ensuring it's not None button_location: dict = submit_button.bounding_box() if button_location is None: assert False, "Submit Button is hidden" button_bottom: float = button_location.get("y") + button_location.get("height") - page.screenshot(path=f"{screenshot_dir}/contact_errors.png", full_page=True) assert button_bottom <= frame_height, "Form content is running off the iframe" + + +def test_contact_form_fields(page: Page): + """ + The embedded Wufoo form loads with somewhere to put an email address and a message + """ + frame = page.locator("#contact-frame") + expect(frame).to_have_attribute("src", re.compile(r"^https://jblive\.wufoo\.com/embed/\w+/$")) + form = page.frame_locator("#contact-frame") + expect(form.locator('input[type="email"]')).to_have_count(1) + expect(form.locator("textarea")).not_to_have_count(0) diff --git a/test/e2e/test_edge-cases.py b/test/e2e/test_edge-cases.py deleted file mode 100644 index b283ce46a..000000000 --- a/test/e2e/test_edge-cases.py +++ /dev/null @@ -1,9 +0,0 @@ -from playwright.sync_api import Page, expect - -""" -Checking for broken tag links with spaces in them -""" -def test_tag_broken_link_spaces(page: Page): - page.goto("/show/linux-unplugged/489/") - page.locator(".tag > a", has_text="linux unplugged").click() - expect(page.locator(".title", has_text="Tag: linux unplugged")).to_be_visible() diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py new file mode 100644 index 000000000..f9f62a1f4 --- /dev/null +++ b/test/e2e/test_episode.py @@ -0,0 +1,150 @@ +import re + +from pytest import mark, param +from playwright.sync_api import APIRequestContext, Locator, Page, expect + +from episode_data import CONTENT_SHOW, Episode, person_type, sample_episodes, sponsor_exists + +SAMPLE_EPISODES: list[Episode] = sample_episodes() + + +def episode_params(): + return mark.parametrize("ep", [param(e, id=e.url, marks=mark.episodes) for e in SAMPLE_EPISODES]) + + +def test_sample_episodes_shape(): + """ + Sample has no duplicates, covers every show, and includes the hand-picked edge cases + """ + urls = [e.url for e in SAMPLE_EPISODES] + assert len(set(urls)) == len(urls) + assert "/show/linux-unplugged/434/" in urls + assert {e.show for e in SAMPLE_EPISODES} == {p.name for p in CONTENT_SHOW.iterdir() if p.is_dir()} + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_page_title(page: Page, ep: Episode): + """ + Tab title is "<title> | <show> <number> | Jupiter Broadcasting", or "<show> <number> | Jupiter Broadcasting" + when the episode title is just the show name and number (themes/jb/layouts/partials/meta.html) + """ + page.goto(ep.url, wait_until="domcontentloaded") + show_and_number = re.escape(f"{ep.data['show_name']} {ep.number}") + expect(page).to_have_title(re.compile(rf"^(.+ \| )?{show_and_number} \| Jupiter Broadcasting$")) + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_player(page: Page, ep: Episode): + """ + Podverse embed for episodes with a podcast_file, otherwise a plain <audio> player + """ + page.goto(ep.url, wait_until="domcontentloaded") + if ep.data.get("podcast_file"): + player = page.locator("#pv-embed-player") + expect(player).to_be_attached() + expect(player).to_have_attribute("src", re.compile(re.escape(ep.data["episode_guid"]))) + else: + expect(page.locator("audio source")).not_to_have_count(0) + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_support_subscribe(page: Page, ep: Episode): + page.goto(ep.url, wait_until="domcontentloaded") + expect(page.locator("a.button", has_text="Support")).to_have_attribute("href", re.compile(r"/membership/$")) + expect(page.locator("a.button", has_text="Subscribe to")).to_be_visible() + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_header_image(page: Page, ep: Episode): + page.goto(ep.url, wait_until="domcontentloaded") + expect(page.locator(f'img[src="{ep.data["header_image"]}"]')).to_be_visible() + + +def _people_column(page: Page, heading: str) -> Locator: + return page.locator(".hosts > .column").filter(has=page.locator(f'h2:text-is("{heading}")')) + + +def _expected_people_hrefs(usernames: list[str]) -> list[str]: + # template silently skips usernames without a people page, and links by the person's type, not the role + return [f"/{person_type(u)}s/{u}/" for u in usernames if person_type(u)] + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_hosts_guests(page: Page, ep: Episode): + page.goto(ep.url, wait_until="domcontentloaded") + for heading, key in (("Hosts", "hosts"), ("Guests", "guests")): + expected = _expected_people_hrefs(ep.data.get(key) or []) + column = _people_column(page, heading) + if key == "guests" and not ep.data.get("guests"): + expect(column).to_have_count(0) + continue + links = column.locator("a[href]") + assert [links.nth(i).get_attribute("href") for i in range(links.count())] == expected + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_sponsors(page: Page, ep: Episode): + """ + Sponsors render in a mobile and a desktop block; only the visible one is counted + """ + page.goto(ep.url, wait_until="domcontentloaded") + expected = [s for s in ep.data.get("sponsors") or [] if sponsor_exists(s)] + sponsor_list = page.locator(".sponsor-list:visible") + if not ep.data.get("sponsors"): + expect(page.locator('h2:text-is("Sponsors")')).to_have_count(0) + return + expect(sponsor_list).to_have_count(1) + expect(sponsor_list.locator(".card")).to_have_count(len(expected)) + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_tags(page: Page, ep: Episode, api_request_context: APIRequestContext): + """ + Every front matter tag is shown, and its tag page exists (covers spaces and apostrophes, issue #534) + """ + page.goto(ep.url, wait_until="domcontentloaded") + tags = page.locator(".tags .tag a") + assert sorted(t.strip() for t in tags.all_text_contents()) == sorted(ep.data.get("tags") or []) + broken = [href for href in (tags.nth(i).get_attribute("href") for i in range(tags.count())) + if not api_request_context.get(href).ok] + assert broken == [] + + +@mark.only_browser("chromium") +@episode_params() +def test_episode_links(page: Page, ep: Episode, api_request_context: APIRequestContext): + """ + Links in the episode body have an href, and same-site ones resolve. External links are not fetched. + """ + page.goto(ep.url, wait_until="domcontentloaded") + hrefs = page.locator(".tile.is-parent.is-8 > .tile.is-child:last-child a").evaluate_all( + "els => els.map(e => e.getAttribute('href'))" + ) + assert all(hrefs), f"empty href in {hrefs}" + internal = [h for h in hrefs if h.startswith("/") and not h.startswith("//")] + assert [h for h in internal if not api_request_context.get(h).ok] == [] + + +def test_episode_lup434_all_browsers(page: Page): + """ + One known-rich episode (3 hosts, 3 guests, sponsors) on every browser + """ + page.goto("/show/linux-unplugged/434/", wait_until="domcontentloaded") + expect(page.locator("#pv-embed-player")).to_be_attached() + expect(_people_column(page, "Hosts").locator("a[href]")).to_have_count(3) + expect(_people_column(page, "Guests").locator("a[href]")).to_have_count(3) + expect(page.locator(".sponsor-list:visible .card")).not_to_have_count(0) + + +@mark.skip("Issue #516") +def test_episode_emoji_tags(): + """ + Emoji tags render a tag page. No sample episode has emoji tags today; add one when #516 is fixed. + """ diff --git a/test/e2e/test_home.py b/test/e2e/test_home.py index f2c42edee..c2a4cb038 100644 --- a/test/e2e/test_home.py +++ b/test/e2e/test_home.py @@ -1,27 +1,37 @@ import re -from pathlib import Path -from typing import Dict, List -from urllib.parse import urlparse -from pytest import fixture, mark -from playwright.sync_api import Page, expect, Locator - -# allows this to be run for every test in this file, -# without it needing to specify it +from datetime import datetime, timezone + +from pytest import fixture +from playwright.sync_api import Page, expect + +from episode_data import CONTENT_SHOW, _published, episodes_by_date, is_active + + @fixture(autouse=True) def setup(page: Page): + """ + Code here will run for every test in this file + """ page.goto("/") -def test_homepage_screenshot(page: Page, screenshot_dir: Path): - page.screenshot(path=f"{screenshot_dir}/home.png", full_page=True) def test_homepage_has_logo(page: Page): + """ + Ensure the large logo and text underneath it is visible on the screen + """ logo = page.locator(".logo") expect(logo).to_be_visible() logo_subtitle = page.locator('.subtitle') - expect(logo_subtitle).to_contain_text('Home to the best shows on Linux, Open Source, Security, Privacy, Community, Development, and News') + expect(logo_subtitle).to_contain_text( + 'Home to the best shows on Linux, Open Source, Security, Privacy, Community, Development, and News' + ) + -def test_pagination(page: Page): +def test_home_pagination(page: Page): + """ + Ensure the pagination loads different episodes + """ first_card = page.locator('.card').nth(0).text_content page_2_button = page.locator('[aria-label="pagination"] >> text=2') page_2_button.click() @@ -30,68 +40,64 @@ def test_pagination(page: Page): first_card_second_page = page.locator('.card').nth(0).text_content assert first_card != first_card_second_page -def test_rss_feeds(page: Page, expected_rss_feeds: List[Dict[str,str]]): - for rss_feed in expected_rss_feeds: - element = page.locator('#rss-feeds-menu > div > a[href^="{}"]'.format(rss_feed['href'])) - expect(element).to_contain_text(rss_feed['title']) - - -def test_dropdowns(page: Page, expected_dropdown_items: Dict[str,List[Dict[str,str]]]): - dropdowns: Locator = page.locator('.navbar-start >> .has-dropdown:visible') - dropdowns_list: List[str] = [dropdown.locator('> a.navbar-link').text_content().strip() for dropdown in dropdowns.all()] - expected_list = [dropdown for dropdown in expected_dropdown_items] - - if (len(difference:=set(dropdowns_list) ^ set(expected_list))): - if (len(unexpected:={dropdown for dropdown in dropdowns_list if dropdown in difference})): - raise KeyError (f'Dropdown(s) {list(unexpected)} missing from expected list') - else: - unexpected = {dropdown for dropdown in expected_list if dropdown in difference} - raise KeyError (f'Expected Dropdown(s) {list(unexpected)} missing from the page') - - for parent in dropdowns.all(): - parent.hover() - dropdown = parent.locator('a.navbar-link').first - - parent_item: str = dropdown.text_content().strip() - - children: Locator = parent.locator('.navbar-dropdown >> .navbar-item') - for childitem in children.all(): - childitem_text: str = childitem.text_content().strip() - - expected_dropdown = expected_dropdown_items.get(parent_item, None) - - if expected_dropdown is None: - raise KeyError(f'{parent_item} is not a expected dropdown') - - expected: Dict[str,str] = next(filter(lambda item: item['title'] == childitem_text , expected_dropdown), None) - - if expected is None: - raise KeyError(f'{childitem_text} is not in expected dropdown items') - - if expected['href'][0] == '/': - assert urlparse(childitem.get_attribute('href')).path == expected['href'] - else: - assert childitem.get_attribute('href') == expected['href'] - - expect(childitem).to_be_visible() - -def test_nav(page: Page, expected_dropdowns, expect_nav_items): - - nav = page.locator('#mainnavigation') - expect(nav).to_be_visible() - dropdown_nav_items = page.locator('.navbar-start > * > a') - count = dropdown_nav_items.count() - for i in range(count): - expect(dropdown_nav_items.nth(i)).to_contain_text(expected_dropdowns[i]['title']) - expect(dropdown_nav_items.nth(i)).to_have_attribute('href', expected_dropdowns[i]['href']) - - - nav_items = page.locator('.navbar-start > a') - count = nav_items.count() - for i in range(count): - expect(nav_items.nth(i)).to_contain_text(expect_nav_items[i]['title']) - expect(nav_items.nth(i)).to_have_attribute('href', expect_nav_items[i]['href']) - - nav_image = page.locator('.navbar-brand > a > img') - expect(nav_image.nth(0)).to_be_visible() +def _expected_slides() -> list[str]: + """ + One slide per active show, newest episode first (themes/jb/layouts/index.html). + Future-dated episodes aren't built, so they don't count toward "newest" + """ + now = datetime.now(timezone.utc) + latest = {} + for show_dir in CONTENT_SHOW.iterdir(): + if show_dir.is_dir() and is_active(show_dir): + dates = [_published(e.data["date"]) for e in episodes_by_date(show_dir)] + dates = [d for d in dates if d <= now] + if dates: + latest[show_dir.name] = max(dates) + return [f"/show/{slug}/" for slug in sorted(latest, key=latest.get, reverse=True)] + + +def test_show_slider_slides(page: Page): + slider = page.locator(".show-slider") + expect(slider).to_have_class(re.compile(r"\bswiper-initialized\b")) + hrefs = slider.locator(".swiper-slide a").evaluate_all("els => els.map(e => new URL(e.href).pathname)") + assert hrefs == _expected_slides() + + +def _slider_at_start(page: Page): + expect(page.locator(".show-slider .swiper-button-prev")).to_have_class(re.compile(r"\bswiper-button-disabled\b")) + expect(page.locator(".show-slider .swiper-button-next")).not_to_have_class(re.compile(r"\bswiper-button-disabled\b")) + + +def _slider_moved(page: Page): + # polls, so a slow transition under parallel load isn't read as "didn't move" + page.wait_for_function( + "() => new DOMMatrix(getComputedStyle(document.querySelector('.show-slider .swiper-wrapper')).transform).m41 < 0" + ) + expect(page.locator(".show-slider .swiper-button-prev")).not_to_have_class(re.compile(r"\bswiper-button-disabled\b")) + + +def test_show_slider_buttons(page: Page): + _slider_at_start(page) + page.locator(".show-slider .swiper-button-next").click() + _slider_moved(page) + page.locator(".show-slider .swiper-button-prev").click() + _slider_at_start(page) + + +def test_show_slider_drag(page: Page): + """ + A mouse drag (Swiper's simulateTouch) moves the slider. The drag is held past Swiper's longSwipesMs (300ms): + a quick flick can't be timed reliably under parallel load (Firefox's synthetic mouse took 538ms), and a held + drag is what someone dragging with a mouse does anyway + """ + _slider_at_start(page) + box = page.locator(".show-slider").bounding_box() + y = box["y"] + box["height"] / 2 + page.mouse.move(box["x"] + box["width"] * 0.8, y) + page.mouse.down() + page.mouse.move(box["x"] + box["width"] * 0.5, y, steps=5) + page.wait_for_timeout(600) + page.mouse.move(box["x"] + box["width"] * 0.2, y, steps=5) + page.mouse.up() + expect(page.locator(".show-slider .swiper-button-prev")).not_to_have_class(re.compile(r"\bswiper-button-disabled\b")) diff --git a/test/e2e/test_infra_configs.py b/test/e2e/test_infra_configs.py index fdddac41d..2cef3aebe 100644 --- a/test/e2e/test_infra_configs.py +++ b/test/e2e/test_infra_configs.py @@ -4,6 +4,10 @@ def test_matrix_well_known( api_request_context: APIRequestContext, ): + """ + Check to make sure the files required for matrix federation and name resolution + are present and contain necessary details + """ well_known_folder = "/.well-known/matrix" well_known_types = { "client": { diff --git a/test/e2e/test_live.py b/test/e2e/test_live.py index 2b234c2fa..66050f67b 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -1,91 +1,92 @@ +import re from pathlib import Path -from typing import Tuple, Callable -from playwright.sync_api import Page, expect, Locator, FrameLocator, Route - -# def test_live_indicator( -# page: Page, -# set_live: Callable, -# screenshot_dir: Path, -# ): -# # intercepting reponses for live event, and make live -# set_live(page) - -# # go to the live page -# page.goto("/live") - -# # validate live button is red -# expect(page.locator("#mainnavigation.is-live").locator("#livebutton"),).to_have_css( -# name="background-color", -# value="rgb(255, 0, 0)", # red -# ) - -# # waiting for peertube iframe to load -# jbtube_video: FrameLocator = page.frame_locator("#liveStream") -# video_player_peertube_icon: Locator = jbtube_video.locator(".peertube-dock-avatar") -# expect(video_player_peertube_icon).to_be_visible() - -# page.evaluate("window.scrollTo(0, 0)") - -# page.screenshot(path=f"{screenshot_dir}/live.png", full_page=True) - - -# def test_mobile_live_indicator( -# mobile_device_tuple: Tuple[Page, str], -# set_live: Callable, -# screenshot_dir: Path, -# ): -# # set mobile page to variable -# mobile_device = mobile_device_tuple[0] -# # set screenshot dir for mobile device -# screenshot_dir = Path( -# screenshot_dir / f"mobile/{mobile_device_tuple[1].replace(' ','-')}/" -# ) - -# # intercepting reponses for live event, and make live -# set_live(mobile_device) - -# mobile_device.goto("/live") - -# navbar: Locator = mobile_device.locator("#mainnavigation.is-live",).locator( -# ".navbar-burger", -# ) - -# # wait for navbar to be visible -# expect(navbar).to_be_visible() - -# # check if live indicator is red -# assert ( -# navbar.evaluate( -# # integrated docs for python evaluate function: https://github.com/microsoft/playwright/blob/a30aac56687598c373c51255308ef5833de0c9bb/docs/src/api/class-jshandle.md?plain=1#L77-L80 -# # use evaluate function in python: https://playwright.dev/python/docs/api/class-page#page-evaluate -# # use getComputerStyle w/playwright: https://stackoverflow.com/a/71433333 -# "element => window.getComputedStyle(element, ':before').backgroundColor" -# ) -# == "rgb(255, 0, 0)" -# ) - -# # waiting for peertube iframe to load -# jbtube_video: FrameLocator = mobile_device.frame_locator("#liveStream") -# video_player_peertube_icon: Locator = jbtube_video.locator(".peertube-dock-avatar") -# expect(video_player_peertube_icon).to_be_visible() - -# mobile_device.evaluate("window.scrollTo(0, 0)") - -# mobile_device.screenshot(path=f"{screenshot_dir}/live-mobile.png", full_page=True) - -# # click navbar to expand -# navbar.click() - -# # repeat test_live_indicator for mobile device -# expect( -# mobile_device.locator("#mainnavigation.is-live",).locator( -# "#livebutton", -# ), -# ).to_have_css( -# name="background-color", -# value="rgb(255, 0, 0)", # red -# ) - -# mobile_device.locator("#mainnavigation").locator( -# ".navbar-menu.is-active" -# ).screenshot(path=f"{screenshot_dir}/live-mobile_navbar.png") + +from pytest import fixture +from playwright.sync_api import Page, Route, expect + +CONFIG: str = (Path(__file__).parents[2] / "config.toml").read_text() +VIDEO_URL: str = re.search(r'^\s*video_url\s*=\s*"([^"]+)"', CONFIG, re.M).group(1) +AUDIO_URL: str = re.search(r'^\s*audio_url\s*=\s*"([^"]+)"', CONFIG, re.M).group(1) +STREAM_HOST: str = re.match(r"https?://[^/]+", VIDEO_URL).group(0) + +# a live (no ENDLIST) media playlist; segment requests are left pending so hls.js never sees a segment error +LIVE_PLAYLIST: str = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:6.0,\nseg0.ts\n" + +# hls.js retries a failed manifest before declaring the stream offline +OFFLINE_TIMEOUT_MS: int = 20_000 + + +class StreamStub: + """ + Answers every request to the live stream host, so tests never touch the real stream + """ + + def __init__(self) -> None: + self.live = False + self.playlist_requests = 0 + + def handle(self, route: Route) -> None: + if not route.request.url.startswith(VIDEO_URL): + return # segments: leave pending + self.playlist_requests += 1 + if self.live: + route.fulfill(status=200, content_type="application/vnd.apple.mpegurl", body=LIVE_PLAYLIST) + else: + route.abort() + + +@fixture +def stream(page: Page) -> StreamStub: + stub = StreamStub() + page.route(f"{STREAM_HOST}/**", stub.handle) + page.route(f"{AUDIO_URL.split('?')[0]}*", lambda route: route.abort()) + return stub + + +def test_live_offline_overlay(page: Page, stream: StreamStub): + """ + When the stream can't be reached, the "Currently Off Air" overlay is shown + """ + page.goto("/live/") + overlay = page.locator("#stream-offline") + expect(overlay).to_have_class(re.compile(r"\bis-visible\b"), timeout=OFFLINE_TIMEOUT_MS) + expect(overlay).to_contain_text("Currently Off Air") + expect(overlay.locator('a[href="/calendar/"]')).to_be_visible() + + +def test_live_online_hides_overlay(page: Page, stream: StreamStub): + """ + "Try Again" reconnects, and once the stream answers the off-air overlay is hidden + """ + page.goto("/live/") + overlay = page.locator("#stream-offline") + expect(overlay).to_have_class(re.compile(r"\bis-visible\b"), timeout=OFFLINE_TIMEOUT_MS) + + stream.live = True + requests_before = stream.playlist_requests + page.locator("#retry-button").click() + + expect(overlay).not_to_have_class(re.compile(r"\bis-visible\b"), timeout=OFFLINE_TIMEOUT_MS) + assert stream.playlist_requests > requests_before + + +def test_live_toggle_audio_video(page: Page, stream: StreamStub): + """ + The toggle button swaps between the video and audio players and relabels itself. It's found by role and + accessible name, so this is also what a screen reader user hears and activates + """ + page.goto("/live/") + audio_player = page.locator("#audio-player") + video_wrapper = page.locator("#video-player .video-wrapper") + + expect(audio_player).to_be_hidden() + expect(video_wrapper).to_be_visible() + + page.get_by_role("button", name="Switch to Audio", exact=True).click() + expect(audio_player).to_be_visible() + expect(video_wrapper).to_be_hidden() + + page.get_by_role("button", name="Switch to Video", exact=True).click() + expect(audio_player).to_be_hidden() + expect(video_wrapper).to_be_visible() + expect(page.get_by_role("button", name="Switch to Audio", exact=True)).to_be_visible() diff --git a/test/e2e/test_llms.py b/test/e2e/test_llms.py index 4e07ce762..01656cc75 100644 --- a/test/e2e/test_llms.py +++ b/test/e2e/test_llms.py @@ -1,179 +1,335 @@ -"""E2E checks for the llms.txt machinery (Hugo-native output formats). - -Pure-HTTP tests (no browser needed), consistent with the llmstxt.org v2 contract: - - /llms.txt at root, text/plain, spec structure - - /llms-full.txt core pages, front-matter-free markdown - - per-show llms.txt (curated) + episodes.json (full enumeration) - - markdown twins at <url>index.md, incl. prefixed special episodes - - discovery links (describedby / alternate) in HTML heads +""" +llms.txt machinery (llmstxt.org v2): /llms.txt, /llms-full.txt, per-show llms.txt + episodes.json, +markdown twins at <url>index.md, and discovery links in HTML heads. -Run: pytest test/e2e/test_llms.py --base-url http://127.0.0.1:18080 +Expected values come from content/ so the tests hold for any content snapshot. +Links may be absolute (production) or site-relative (CI builds with baseURL "/"). """ import re +from dataclasses import dataclass +from pathlib import Path -import pytest - -requests = pytest.importorskip("requests") - - -def _get(base_url, path): - return requests.get(base_url.rstrip("/") + path, timeout=30) +from pytest import mark, param +from playwright.sync_api import APIRequestContext, APIResponse, Page, expect +from episode_data import CONTENT, CONTENT_SHOW, Episode, episodes_by_date, is_active, sample_episodes, sponsor_exists -def test_root_llms_txt(base_url): - r = _get(base_url, "/llms.txt") - assert r.ok - assert r.headers["content-type"].startswith("text/plain") - assert "Jupiter Broadcasting" in r.text - assert "## Shows" in r.text - assert "/show/linux-unplugged/llms.txt" in r.text +HOST = r"(?:https?://[^/)\s]+)?" +LLMS_SECTIONS = ["/show/", "/hosts/", "/guests/"] +SITE_PAGES = ["/membership/", "/live/", "/community/", "/hosts/", "/sponsors/", "/contact/"] -def test_llms_full_txt(base_url): - r = _get(base_url, "/llms-full.txt") - assert r.ok - assert r.headers["content-type"].startswith("text/plain") - assert "Membership" in r.text - assert "+++" not in r.text # front matter stripped +def _yaml_field(path: Path, key: str) -> str | None: + # flat YAML front matter; the test image has no YAML parser + match = re.search(rf"^{key}: *[\"']?(.+?)[\"']?\s*$", path.read_text(), re.M) + return match.group(1) if match else None -def test_per_show_llms_and_markdown_twins(base_url): - r = _get(base_url, "/show/linux-unplugged/llms.txt") - assert r.ok - assert r.headers["content-type"].startswith("text/plain") - assert "## Most recent episodes" in r.text - assert "## Episode archive" in r.text - # curated file stays small: full enumeration lives in episodes.json - assert len(r.text) < 20_000, "per-show llms.txt should be curated, not a full index" - assert "/show/linux-unplugged/episodes.json" in r.text - # twin links may be absolute (production) or site-relative (CI/dev) - m = re.search(r"\]\(((?:https?://[^)]+)?/show/linux-unplugged/\d+/index\.md)\)", r.text) - assert m, "episode entries must link to markdown twins" - twin = re.sub(r"^https?://[^/]+", "", m.group(1)) - t = _get(base_url, twin) - assert t.ok - assert "URL:" in t.text +def _toml_field(path: Path, key: str) -> str: + return re.search(rf'^{key}\s*=\s*"(.*)"\s*$', path.read_text(), re.M).group(1) -def test_llms_not_rendered_on_non_show_sections(base_url): - # outputs are enabled per-show in front matter, not site-wide sections - assert not _get(base_url, "/show/llms.txt").ok - assert not _get(base_url, "/hosts/llms.txt").ok - assert not _get(base_url, "/guests/llms.txt").ok +@dataclass(frozen=True) +class Show: + slug: str + title: str + active: bool + rss: str | None + episodes: tuple[Episode, ...] # oldest first + @property + def newest_first(self) -> list[Episode]: + return list(reversed(self.episodes)) + + +def _load_show(show_dir: Path) -> Show: + episodes = [e for e in episodes_by_date(show_dir) if e.data.get("type") == "episode"] + return Show( + slug=show_dir.name, + title=_toml_field(show_dir / "_index.md", "title"), + active=is_active(show_dir), + rss=_yaml_field(show_dir / "subscribe.md", "rss") if (show_dir / "subscribe.md").exists() else None, + episodes=tuple(episodes), + ) + + +SHOWS: list[Show] = [_load_show(d) for d in sorted(CONTENT_SHOW.iterdir()) if (d / "_index.md").exists()] +SHOW_PARAMS = [param(s, id=s.slug) for s in SHOWS] + +ACTIVE_HOSTS: list[str] = sorted( + p.stem + for p in (CONTENT / "people").glob("*.md") + if _yaml_field(p, "type") == "host" and _yaml_field(p, "is_archived") != "true" +) + +# a hosts term only exists once the person hosts an episode +HOST_TERMS: list[str] = sorted( + {h for show in SHOWS for e in show.episodes for h in e.data.get("hosts") or []} & set(ACTIVE_HOSTS) +) + +SAMPLE_EPISODES: list[Episode] = sample_episodes() + + +def _get(api: APIRequestContext, path: str, content_type: str) -> APIResponse: + response = api.get(path) + assert response.status == 200, f"{path}: expected 200, got {response.status}" + assert response.headers["content-type"].startswith(content_type), ( + f"{path}: expected {content_type}, got {response.headers['content-type']}" + ) + return response + + +def _section(text: str, heading: str, until: str | None = None) -> str: + assert heading in text, f"missing heading {heading!r}" + body = text.split(heading, 1)[1] + if until: + assert until in body, f"missing heading {until!r} after {heading!r}" + body = body.split(until, 1)[0] + return body + + +def _episode_date(ep: Episode) -> str: + # Hugo formats .Date in the front matter's own offset, so the date part is the local date + return str(ep.data["date"])[:10] + + +def _slug(ep: Episode) -> str: + return str(ep.data.get("slug") or ep.number) + + +def test_root_llms_txt(api_request_context: APIRequestContext): + """ + /llms.txt follows the spec layout: H1, summary blockquote, then Shows / Site / Optional sections in order + """ + text = _get(api_request_context, "/llms.txt", "text/plain").text() + assert text.startswith("# Jupiter Broadcasting\n"), "H1 must be the first line" + assert "\n> " in text, "missing summary blockquote" + positions = [text.find(h) for h in ("## Shows", "## Site", "## Optional")] + assert -1 not in positions and positions == sorted(positions), "sections missing or out of order" + assert re.search(rf"\]\({HOST}/llms-full\.txt\)", text), "Optional must link llms-full.txt" + + +def test_root_shows_match_navigation( + api_request_context: APIRequestContext, expected_dropdown_items: dict[str, list[dict[str, str]]] +): + """ + ## Shows lists exactly the shows in the navbar Shows dropdown + """ + shows = _section(_get(api_request_context, "/llms.txt", "text/plain").text(), "## Shows", "## Site") + listed = set(re.findall(rf"\]\({HOST}(/show/[^/]+/)llms\.txt\)", shows)) + assert listed == {item["href"] for item in expected_dropdown_items["Shows"]} + + +@mark.parametrize("show", SHOW_PARAMS) +def test_root_show_entry(api_request_context: APIRequestContext, show: Show): + """ + Active shows are under ## Shows linking their llms.txt, archived ones under ## Optional linking their + markdown twin; both carry the content episode count and the subscribe page RSS feed + """ + text = _get(api_request_context, "/llms.txt", "text/plain").text() + if show.active: + section, target = _section(text, "## Shows", "## Site"), "llms.txt" + else: + section, target = _section(text, "## Optional"), "index.md" + + entry = re.search( + rf"^- \[{re.escape(show.title.strip())}\]\({HOST}/show/{show.slug}/{re.escape(target)}\): " + rf"(\d+) episodes\.(.*)$", + section, + re.M, + ) + assert entry, f"{show.slug} not listed under the expected section" + assert int(entry.group(1)) == len(show.episodes), "episode count differs from content" + if show.rss: + assert f"[RSS]({show.rss})" in entry.group(2), "missing RSS link" + + +def test_root_site_links(api_request_context: APIRequestContext): + """ + ## Site links each core page's markdown twin, and the twin resolves + """ + site = _section(_get(api_request_context, "/llms.txt", "text/plain").text(), "## Site", "## Optional") + for path in SITE_PAGES: + assert re.search(rf"\]\({HOST}{re.escape(path)}index\.md\)", site), f"{path} not linked" + _get(api_request_context, f"{path}index.md", "text/markdown") + + +def test_llms_full_txt(api_request_context: APIRequestContext): + """ + /llms-full.txt concatenates core pages as plain markdown: no front matter, no HTML escaping + """ + text = _get(api_request_context, "/llms-full.txt", "text/plain").text() + assert "Membership" in text + assert "Brent Gervais" in text + assert "+++" not in text, "TOML front matter leaked" + assert "avatar_small" not in text, "YAML front matter leaked" + assert "+" not in text and "&" not in text, "HTML escaping in plain text output" + + +@mark.parametrize("path", LLMS_SECTIONS) +def test_llms_only_on_show_sections(api_request_context: APIRequestContext, path: str): + """ + llms output is enabled per show in front matter, not for every section + """ + response = api_request_context.get(f"{path}llms.txt") + assert response.status == 404, f"{path}llms.txt should not exist, got {response.status}" + + +@mark.parametrize("show", SHOW_PARAMS) +def test_show_llms_txt(api_request_context: APIRequestContext, show: Show): + """ + Per-show llms.txt is curated: the 10 newest episodes plus an archive summary pointing at episodes.json + """ + text = _get(api_request_context, f"/show/{show.slug}/llms.txt", "text/plain").text() + assert text.startswith(f"# {show.title}\n"), "H1 must be the show title" + assert len(text) < 20_000, "per-show llms.txt should be curated, not a full index" + assert re.search(rf"\]\({HOST}/show/{show.slug}/episodes\.json\)", text), "missing episodes.json link" + assert re.search(rf"\]\({HOST}/show/{show.slug}/index\.md\)", text), "missing show twin link" + + recent = _section(text, f"## Most recent episodes (of {len(show.episodes)})", "## Episode archive") + entries = re.findall(rf"^- \[(\d{{4}}-\d{{2}}-\d{{2}}) · .*\]\({HOST}(/show/{show.slug}/[^/]+/)index\.md\)", recent, re.M) + assert [d for d, _ in entries] == [_episode_date(e) for e in show.newest_first[:10]], ( + "recent episodes must be the 10 newest, newest first" + ) + + numbered = [int(e.number) for e in show.episodes if e.number.isdigit()] + archive = _section(text, "## Episode archive") + assert f"{len(numbered)} numbered episodes, {min(numbered)}-{max(numbered)}." in archive + specials = len(show.episodes) - len(numbered) + if specials: + assert f"Plus {specials} special episodes" in archive + + +@mark.parametrize("show", SHOW_PARAMS) +def test_show_episodes_json(api_request_context: APIRequestContext, show: Show): + """ + episodes.json enumerates every episode in content (incl. prefixed specials like the-launch T-0), newest first + """ + data = _get(api_request_context, f"/show/{show.slug}/episodes.json", "application/json").json() + assert data["show"] == show.slug + assert data["title"] == show.title + assert data["count"] == len(show.episodes), "count differs from content" + assert len(data["episodes"]) == data["count"] + assert {e["slug"] for e in data["episodes"]} == {_slug(e) for e in show.episodes} + + dates = [e["date"] for e in data["episodes"]] + assert dates == sorted(dates, reverse=True), "episodes must be newest first" -def test_episodes_json(base_url): - r = _get(base_url, "/show/linux-unplugged/episodes.json") - assert r.ok - assert r.headers["content-type"].startswith("application/json") - data = r.json() - # counts depend on the content snapshot; structure must hold everywhere - assert data["count"] == len(data["episodes"]) > 0 for ep in data["episodes"]: - assert ep["slug"] and ep["title"] and ep["date"] and ep["url"] - - -def test_prefixed_special_episodes_included(base_url): - """The-launch 'T-' and office-hours 'p' episodes must be enumerated.""" - data = _get(base_url, "/show/the-launch/episodes.json").json() - episodes = {e["episode"] for e in data["episodes"]} - assert "T-0" in episodes and "T-1" in episodes - assert data["count"] == len(data["episodes"]) - data = _get(base_url, "/show/office-hours/episodes.json").json() - assert any(str(e["episode"]).startswith("p") for e in data["episodes"]) - - -def test_episode_markdown_twins(base_url): - assert _get(base_url, "/show/linux-unplugged/650/index.md").ok - r = _get(base_url, "/show/the-launch/t-2/index.md") - assert r.ok # prefixed slug twin resolves at its lowercase permalink - r = _get(base_url, "/community/index.md") - assert r.ok - assert "+++" not in r.text # twin is front-matter-free markdown - - -def test_discovery_links_on_html_pages(base_url): - r = _get(base_url, "/") - assert 'rel="describedby"' in r.text - assert "/llms.txt" in r.text - r = _get(base_url, "/show/linux-unplugged/") - assert 'rel="describedby"' in r.text - assert "/show/linux-unplugged/llms.txt" in r.text - - -def test_root_shows_active_archived_split(base_url): - r = _get(base_url, "/llms.txt").text - shows = r.split("## Shows")[1].split("## Site")[0] - assert "LINUX Unplugged" in shows - assert "Coder Radio" not in shows # archived: not in nav, not in ## Shows - optional = r.split("## Optional")[1] - assert "Coder Radio" in optional and "Self-Hosted" in optional - # link may be absolute (production) or site-relative (CI/dev); never https-assuming - assert "/llms-full.txt)" in r - - -def test_hosts_twin_lists_hosts(base_url): - r = _get(base_url, "/hosts/index.md") - assert r.ok - for h in ("brent", "chris", "wes"): - assert f"/hosts/{h}/index.md" in r.text - # individual host term twin lists episodes - r = _get(base_url, "/hosts/brent/index.md") - assert r.ok - assert "/show/" in r.text - # people twin carries front-matter facts, not just a slug - r = _get(base_url, "/people/brent/index.md") - assert r.ok - assert "brentgervais.com" in r.text - assert "Username: brent" in r.text - - -def test_llms_full_no_leaked_yaml_frontmatter(base_url): - r = _get(base_url, "/llms-full.txt").text - assert "avatar_small" not in r # YAML front matter stripped - assert "+" not in r and "&" not in r # no HTML escaping in text output - assert "Brent Gervais" in r - - -def test_show_rss_feed_links(base_url): - root = _get(base_url, "/llms.txt").text - shows = root.split("## Shows")[1].split("## Site")[0] - assert "](https://linuxunplugged.com/rss)" in shows - optional = root.split("## Optional")[1] - assert "](https://coder.show/rss)" in optional # archived shows too - show_twin = _get(base_url, "/show/linux-unplugged/index.md").text - assert "RSS feed: [https://linuxunplugged.com/rss]" in show_twin - - -def test_episode_twin_sponsors_and_transcripts(base_url): - r = _get(base_url, "/show/linux-unplugged/650/index.md") - assert r.ok - assert "Sponsors:" in r.text - assert "defined.net" in r.text - assert "Transcripts:" in r.text - assert ".vtt" in r.text - - -def test_episodes_json_rich_fields(base_url): - import json as _json - r = _get(base_url, "/show/linux-unplugged/episodes.json") - data = _json.loads(r.text) - ep = data["episodes"][0] - assert ep["title"] # titles, not just slugs - assert isinstance(ep["hosts"], list) - assert "guests" in ep and isinstance(ep["guests"], list) - assert isinstance(ep["transcripts"], list) - assert ep["transcripts"] and ep["transcripts"][0]["url"].endswith(".vtt") - - -def test_person_details_on_term_twins(base_url): - r = _get(base_url, "/hosts/brent/index.md") - assert r.ok - assert r.text.startswith("# Brent Gervais") # real name, not the term slug - assert "Username: brent" in r.text - assert "brentgervais.com" in r.text # bio/homepage rendered, not just episodes - g = _get(base_url, "/guests/") - if g.ok: # guests taxonomy present on this build - gt = _get(base_url, "/guests/index.md") - assert gt.ok - assert "{{" not in gt.text # no template errors + assert ep["title"], f"{ep['slug']}: empty title" + assert re.fullmatch(rf"{HOST}/show/{show.slug}/{re.escape(ep['slug'].lower())}/", ep["url"]), ep["url"] + assert isinstance(ep["hosts"], (list, type(None))), f"{ep['slug']}: hosts" + assert isinstance(ep["guests"], (list, type(None))), f"{ep['slug']}: guests" + assert all(t["url"] for t in ep["transcripts"] or []), f"{ep['slug']}: transcript without url" + + +@mark.parametrize("show", SHOW_PARAMS) +def test_show_llms_newest_oldest_match_json(api_request_context: APIRequestContext, show: Show): + """ + The Newest/Oldest twin links in llms.txt agree with the episodes.json ordering + """ + text = _get(api_request_context, f"/show/{show.slug}/llms.txt", "text/plain").text() + episodes = _get(api_request_context, f"/show/{show.slug}/episodes.json", "application/json").json()["episodes"] + for label, ep in (("Newest", episodes[0]), ("Oldest", episodes[-1])): + assert re.search(rf"{label}: `{HOST}/show/{show.slug}/{re.escape(ep['slug'].lower())}/index\.md`", text), label + + +@mark.parametrize("show", SHOW_PARAMS) +def test_show_markdown_twin(api_request_context: APIRequestContext, show: Show): + """ + Show twin has the title, its URL, links to the show llms.txt and the RSS feed + """ + text = _get(api_request_context, f"/show/{show.slug}/index.md", "text/markdown").text() + assert text.startswith(f"# {show.title}\n"), "H1 must be the show title" + assert re.search(rf"^URL: {HOST}/show/{show.slug}/$", text, re.M), "missing URL line" + assert re.search(rf"\[llms\.txt\]\({HOST}/show/{show.slug}/llms\.txt\)", text), "missing llms.txt link" + if show.rss: + assert f"RSS feed: [{show.rss}]({show.rss})" in text + assert "{{" not in text and "+++" not in text, "template or front matter leaked" + + +@mark.parametrize("ep", [param(e, id=e.url, marks=mark.episodes) for e in SAMPLE_EPISODES]) +def test_episode_markdown_twin(api_request_context: APIRequestContext, ep: Episode): + """ + Episode twin resolves at the lowercase permalink and carries the front matter facts + """ + text = _get(api_request_context, f"{ep.url}index.md", "text/markdown").text() + assert text.splitlines()[0].strip() == f"# {ep.data['title']}".strip(), "H1 must be the episode title" + assert re.search(rf"^URL: {HOST}{re.escape(ep.url)}$", text, re.M), "missing URL line" + assert f"- Episode: {ep.number}\n" in text + assert f"- Date: {_episode_date(ep)}\n" in text + if ep.data.get("hosts"): + assert f"- Hosts: {', '.join(ep.data['hosts'])}\n" in text + if ep.data.get("guests"): + assert f"- Guests: {', '.join(ep.data['guests'])}\n" in text + for transcript in ep.data.get("transcripts") or []: + assert f"]({transcript['url']})" in text, f"missing transcript {transcript['url']}" + sponsors = [s for s in ep.data.get("sponsors") or [] if sponsor_exists(s)] + if sponsors: + assert "\nSponsors:\n" in text + assert len(re.findall(rf"^- \[.+\]\({HOST}/sponsors/[^)]+index\.md\)", text, re.M)) == len(sponsors) + assert "{{" not in text and "+++" not in text, "template or front matter leaked" + + +def test_hosts_twin_lists_active_hosts(api_request_context: APIRequestContext): + text = _get(api_request_context, "/hosts/index.md", "text/markdown").text() + missing = [h for h in HOST_TERMS if not re.search(rf"\]\({HOST}/hosts/{h}/index\.md\)", text)] + assert not missing, f"active hosts missing from /hosts/index.md: {missing}" + + +@mark.parametrize("username", HOST_TERMS) +def test_host_term_twin(api_request_context: APIRequestContext, username: str): + """ + Host term twin shows the person's real name and people-page details, then their episodes + """ + person = CONTENT / "people" / f"{username}.md" + text = _get(api_request_context, f"/hosts/{username}/index.md", "text/markdown").text() + assert text.startswith(f"# {_yaml_field(person, 'title')}\n"), "H1 must be the real name, not the slug" + assert f"Username: {username}\n" in text + homepage = _yaml_field(person, "homepage") + if homepage and homepage != "null": + assert f"Homepage: {homepage}" in text + assert re.search(rf"\]\({HOST}/show/[^)]+/index\.md\)", text), "term twin must list episodes" + + +@mark.parametrize("username", ACTIVE_HOSTS) +def test_people_twin(api_request_context: APIRequestContext, username: str): + person = CONTENT / "people" / f"{username}.md" + text = _get(api_request_context, f"/people/{username}/index.md", "text/markdown").text() + assert text.startswith(f"# {_yaml_field(person, 'title')}\n") + assert f"Username: {username}\n" in text + assert re.search(rf"\]\({HOST}/hosts/{username}/index\.md\)", text), "missing link to hosts term twin" + + +def test_guests_twin(api_request_context: APIRequestContext): + text = _get(api_request_context, "/guests/index.md", "text/markdown").text() + assert re.search(rf"\]\({HOST}/guests/[^/]+/index\.md\)", text), "guests twin lists no guests" + assert "{{" not in text, "template error" + + +@mark.only_browser("chromium") +def test_home_discovery_link(page: Page): + """ + Home points at /llms.txt; it has no markdown twin (home outputs are html, rss, llms, llmsfull) + """ + page.goto("/") + expect(page.locator('link[rel="describedby"]')).to_have_attribute("href", re.compile(rf"^{HOST}/llms\.txt$")) + expect(page.locator('link[rel="alternate"][type="text/markdown"]')).to_have_count(0) + + +@mark.only_browser("chromium") +@mark.parametrize( + "path, llms", + [param("/community/", "/llms.txt", id="community")] + + [param(f"/show/{s.slug}/", f"/show/{s.slug}/llms.txt", id=s.slug) for s in SHOWS] + + [param(SAMPLE_EPISODES[0].url, f"/show/{SAMPLE_EPISODES[0].show}/llms.txt", id="episode")], +) +def test_discovery_links(page: Page, path: str, llms: str): + """ + Every page points at its nearest llms.txt (describedby) and its markdown twin (alternate) + """ + page.goto(path) + expect(page.locator('link[rel="describedby"]')).to_have_attribute("href", re.compile(rf"^{HOST}{re.escape(llms)}$")) + expect(page.locator('link[rel="alternate"][type="text/markdown"]')).to_have_attribute( + "href", re.compile(rf"^{HOST}{re.escape(path)}index\.md$") + ) diff --git a/test/e2e/test_membership.py b/test/e2e/test_membership.py new file mode 100644 index 000000000..9d9d4e28a --- /dev/null +++ b/test/e2e/test_membership.py @@ -0,0 +1,37 @@ +import re +from pathlib import Path + +from pytest import fixture, mark +from playwright.sync_api import Page, expect + +MEMBERSHIP_MD = Path(__file__).parents[2] / "content/membership/index.md" +MEMBERSHIP_LINK = re.compile( + r"\[([^\]]+)\]\((https://(?:jupitersignal\.memberful\.com|www\.jupiter\.party)/[^)]*)\)" +) +MEMBERSHIP_LINKS = MEMBERSHIP_LINK.findall(MEMBERSHIP_MD.read_text(encoding="utf-8")) + + +@fixture(autouse=True) +def setup(page: Page): + """ + Code here will run for every test in this file + """ + page.goto("/membership/") + + +def test_membership_links_found(): + """ + Guard against the regex silently matching nothing + """ + assert MEMBERSHIP_LINKS + + +@mark.parametrize("text,href", MEMBERSHIP_LINKS, ids=[href for _, href in MEMBERSHIP_LINKS]) +def test_membership_links(page: Page, text: str, href: str): + """ + Ensure every membership link in content/membership/index.md renders with the same href and text + """ + link = page.locator(f'a[href="{href}"]') + expect(link).to_have_count(1) + expect(link).to_be_visible() + expect(link).to_have_text(text) diff --git a/test/e2e/test_meta.py b/test/e2e/test_meta.py new file mode 100644 index 000000000..e8f0cf08c --- /dev/null +++ b/test/e2e/test_meta.py @@ -0,0 +1,100 @@ +import re +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime + +from pytest import mark, param +from playwright.sync_api import APIRequestContext, Page, expect + +from episode_data import CONTENT, CONTENT_SHOW, Episode, episodes_by_date + +SITE_PAGES = ["/", "/show/", "/archive/", "/membership/", "/live/", "/community/", "/sponsors/", "/contact/", "/calendar/", "/people/"] +SITEMAP_NS = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"} +OGIMAGE = re.search(r'^ogimage\s*=\s*"([^"]+)"', (CONTENT.parent / "config/_default/params.toml").read_text(), re.M).group(1) + +EPISODES = {s.name: episodes_by_date(s) for s in sorted(CONTENT_SHOW.iterdir()) if (s / "_index.md").exists()} +ALL_EPISODES = [e for eps in EPISODES.values() for e in eps] + + +def _path(url: str) -> str: + return re.sub(r"^https?://[^/]+", "", url) + + +def _published(episode: Episode) -> datetime: + parsed = datetime.fromisoformat(episode.data["date"]) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _xml(api: APIRequestContext, path: str) -> ET.Element: + response = api.get(path) + assert response.status == 200, f"{path} returned {response.status}" + assert "xml" in response.headers["content-type"], response.headers["content-type"] + return ET.fromstring(response.body()) + + +def test_sitemap(api_request_context: APIRequestContext): + """ + sitemap.xml lists the core pages, every show, and every episode + """ + locs = {_path(loc.text) for loc in _xml(api_request_context, "/sitemap.xml").iterfind("s:url/s:loc", SITEMAP_NS)} + expected = set(SITE_PAGES) | {f"/show/{s}/" for s in EPISODES} | {e.url for e in ALL_EPISODES} + missing = sorted(expected - locs) + assert not missing, f"{len(missing)} pages missing from sitemap.xml, e.g. {missing[:5]}" + + +def test_rss_feed(api_request_context: APIRequestContext): + """ + /index.xml is RSS 2.0 with complete items, newest first, starting at the newest episode + """ + rss = _xml(api_request_context, "/index.xml") + assert rss.tag == "rss" and rss.get("version") == "2.0" + items = rss.findall("channel/item") + assert items, "feed has no items" + for item in items: + assert item.findtext("title") and item.findtext("link") and item.findtext("pubDate"), ET.tostring(item)[:200] + dates = [parsedate_to_datetime(item.findtext("pubDate")) for item in items] + assert dates == sorted(dates, reverse=True), "items must be newest first" + newest = max(_published(e) for e in ALL_EPISODES) + newest_urls = {e.url for e in ALL_EPISODES if _published(e) == newest} + assert _path(items[0].findtext("link")) in newest_urls, "first item should be the newest episode" + + +def _show_header_image(slug: str) -> str: + return re.search(r'^header_image\s*=\s*"([^"]+)"', (CONTENT_SHOW / slug / "_index.md").read_text(), re.M).group(1) + + +def _meta_params() -> list: + lup = EPISODES["linux-unplugged"][-1] + return [ + param("/", None, id="home"), + param("/show/linux-unplugged/", _show_header_image("linux-unplugged"), id="show"), + param(lup.url, lup.data.get("header_image"), id="episode"), + param("/hosts/chris/", None, id="host"), + param("/community/irc/", None, id="community"), + ] + + +@mark.only_browser("chromium") +@mark.parametrize("path, image", _meta_params()) +def test_social_meta(page: Page, api_request_context: APIRequestContext, path: str, image: str | None): + """ + Open Graph / Twitter tags match the page: same title, canonical URL, and the header image (or the site default) + """ + page.goto(path, wait_until="domcontentloaded") + title = page.title() + head = page.locator("head") + for selector in ('meta[property="og:title"]', 'meta[name="twitter:title"]'): + expect(head.locator(selector)).to_have_attribute("content", title) + for selector in ('meta[property="og:url"]', 'meta[name="twitter:url"]', 'link[rel="canonical"]'): + attr = "href" if selector.startswith("link") else "content" + expect(head.locator(selector)).to_have_attribute(attr, re.compile(rf"^(https?://[^/]+)?{re.escape(path)}$")) + expect(head.locator('meta[property="og:description"]')).to_have_attribute("content", re.compile(r"\S")) + + og_image = head.locator('meta[property="og:image"]').get_attribute("content") + expect(head.locator('meta[name="twitter:image"]')).to_have_attribute("content", og_image) + if image: + assert _path(og_image) == _path(image), "og:image should be the page's header_image" + else: + assert _path(og_image) == OGIMAGE, "pages without a header_image use the site default" + response = api_request_context.get(OGIMAGE) + assert response.status == 200, f"default og:image {OGIMAGE} returned {response.status}" diff --git a/test/e2e/test_mobile.py b/test/e2e/test_mobile.py new file mode 100644 index 000000000..10ac0b340 --- /dev/null +++ b/test/e2e/test_mobile.py @@ -0,0 +1,89 @@ +from typing import Tuple + +from pytest import mark +from playwright.sync_api import Page, expect + +# matches Bulma's $tablet (themes/jb/assets/css/libs/bulma/sass/utilities/initial-variables.sass) +BULMA_TABLET = 769 + +OVERFLOW_PAGES = [ + "/", + "/show/linux-unplugged/", + "/show/linux-unplugged/434/", + "/people/", +] + +# lup 434 has sponsors, hosts and guests +EPISODE = "/show/linux-unplugged/434/" + + +def _overflowing(page: Page) -> list[str]: + # compare against the emulated device width, not innerWidth: mobile Chromium widens the + # layout viewport to fit overflowing content, which would hide the bug + width = page.viewport_size["width"] + return page.evaluate( + """width => { + const over = e => e && e.getBoundingClientRect().right > width + 1; + const inFixed = e => { for (; e; e = e.parentElement) if (getComputedStyle(e).position === 'fixed') return true; return false; }; + // outermost offenders only: children of an overflowing element just inherit its overflow + return [...document.querySelectorAll('body *')] + .filter(e => e.getBoundingClientRect().width > 0 && over(e) && !over(e.parentElement) && !inFixed(e)) + .map(e => `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''}.${[...e.classList].join('.')}`) + .slice(0, 10); + }""", + width, + ) + + +def test_mobile_burger_menu(mobile_device_tuple: Tuple[Page, str]): + """ + Every device is under the 1220px navbar breakpoint, so the menu is collapsed behind the burger + """ + page, _ = mobile_device_tuple + page.goto("/") + burger = page.locator(".navbar-burger") + menu = page.locator("#navbarBasicExample") + contact = menu.locator('a.navbar-item[href$="/contact/"]') + + expect(burger).to_be_visible() + expect(burger).to_have_attribute("aria-expanded", "false") + expect(contact).to_be_hidden() + + burger.click() + expect(menu).to_have_class("navbar-menu is-active") + expect(burger).to_have_attribute("aria-expanded", "true") + expect(contact).to_be_visible() + expect(menu.locator('a.navbar-item[href$="/show/linux-unplugged/"]')).to_be_visible() + + burger.click() + expect(burger).to_have_attribute("aria-expanded", "false") + expect(contact).to_be_hidden() + + +@mark.parametrize("path", OVERFLOW_PAGES) +def test_mobile_no_horizontal_overflow(mobile_device_tuple: Tuple[Page, str], path: str): + page, _ = mobile_device_tuple + page.goto(path, wait_until="domcontentloaded") + width = page.viewport_size["width"] + assert page.evaluate("document.documentElement.scrollWidth") <= width, _overflowing(page) + + +def test_mobile_episode_sponsor_block(mobile_device_tuple: Tuple[Page, str]): + """ + The episode page renders sponsors twice; the copy above the content only shows below Bulma's tablet width + """ + page, _ = mobile_device_tuple + page.goto(EPISODE, wait_until="domcontentloaded") + visible = page.locator(".sponsor-list:visible") + expect(visible).to_have_count(1) + in_mobile_block = page.locator(".is-hidden-tablet .sponsor-list:visible") + expect(in_mobile_block).to_have_count(1 if page.viewport_size["width"] < BULMA_TABLET else 0) + + +def test_mobile_episode_player_fits(mobile_device_tuple: Tuple[Page, str]): + page, _ = mobile_device_tuple + page.goto(EPISODE, wait_until="domcontentloaded") + player = page.locator("#pv-embed-player") + expect(player).to_be_visible() + box = player.bounding_box() + assert box["x"] >= 0 and box["x"] + box["width"] <= page.viewport_size["width"] + 1, box diff --git a/test/e2e/test_navigation.py b/test/e2e/test_navigation.py new file mode 100644 index 000000000..6a7addf35 --- /dev/null +++ b/test/e2e/test_navigation.py @@ -0,0 +1,86 @@ +from urllib.parse import urlparse +from pytest import fixture +from playwright.sync_api import Page, expect, Locator + + +@fixture(autouse=True) +def setup(page: Page): + """ + Code here will run for every test in this file + """ + page.goto("/") + + +def test_nav_rss_feeds(page: Page, expected_rss_feeds: list[dict[str, str]]): + """ + Ensure the RSS feeds menu contains all the fields specified in the conftest file + """ + for rss_feed in expected_rss_feeds: + element = page.locator('#rss-feeds-menu > div > a[href^="{}"]'.format(rss_feed['href'])) + expect(element).to_contain_text(rss_feed['title']) + + +def test_nav_dropdowns(page: Page, expected_dropdown_items: dict[str, list[dict[str, str]]]): + """ + Ensure dropdowns on the page match conftest exactly (no missing or unexpected items) and every item links correctly + """ + dropdowns: Locator = page.locator('.navbar-start >> .has-dropdown:visible') + dropdowns_list: list[str] = [dropdown.locator('> a.navbar-link').text_content().strip() for dropdown in dropdowns.all()] + expected_list = [dropdown for dropdown in expected_dropdown_items] + + if (len(difference:=set(dropdowns_list) ^ set(expected_list))): + if (len(unexpected:={dropdown for dropdown in dropdowns_list if dropdown in difference})): + raise KeyError (f'Dropdown(s) {list(unexpected)} missing from expected list') + else: + unexpected = {dropdown for dropdown in expected_list if dropdown in difference} + raise KeyError (f'Expected Dropdown(s) {list(unexpected)} missing from the page') + + for parent in dropdowns.all(): + parent.hover() + dropdown = parent.locator('a.navbar-link').first + + parent_item: str = dropdown.text_content().strip() + + children: Locator = parent.locator('.navbar-dropdown >> .navbar-item') + for childitem in children.all(): + childitem_text: str = childitem.text_content().strip() + + expected_dropdown = expected_dropdown_items.get(parent_item, None) + + if expected_dropdown is None: + raise KeyError(f'{parent_item} is not a expected dropdown') + + expected: dict[str, str] = next(filter(lambda item: item['title'] == childitem_text , expected_dropdown), None) + + if expected is None: + raise KeyError(f'{childitem_text} is not in expected dropdown items') + + if expected['href'][0] == '/': + assert urlparse(childitem.get_attribute('href')).path == expected['href'] + else: + assert childitem.get_attribute('href') == expected['href'] + + expect(childitem).to_be_visible() + + +def test_nav(page: Page, expected_dropdowns, expect_nav_items): + """ + Test main navigation items + """ + nav = page.locator('#mainnavigation') + expect(nav).to_be_visible() + dropdown_nav_items = page.locator('.navbar-start > * > a') + count = dropdown_nav_items.count() + for i in range(count): + expect(dropdown_nav_items.nth(i)).to_contain_text(expected_dropdowns[i]['title']) + expect(dropdown_nav_items.nth(i)).to_have_attribute('href', expected_dropdowns[i]['href']) + + nav_items = page.locator('.navbar-start > a') + count = nav_items.count() + for i in range(count): + expect(nav_items.nth(i)).to_contain_text(expect_nav_items[i]['title']) + expect(nav_items.nth(i)).to_have_attribute('href', expect_nav_items[i]['href']) + + nav_image = page.locator('.navbar-brand > a > img') + + expect(nav_image.nth(0)).to_be_visible() diff --git a/test/e2e/test_people.py b/test/e2e/test_people.py new file mode 100644 index 000000000..5dcf39a6a --- /dev/null +++ b/test/e2e/test_people.py @@ -0,0 +1,61 @@ +import re +from pathlib import Path + +from pytest import fixture +from playwright.sync_api import Page, expect + +PEOPLE_DIR = Path(__file__).parents[2] / "content/people" + + +def front_matter_field(text: str, key: str) -> str | None: + # People front matter is flat YAML and the test image has no YAML parser. + match = re.search(rf"^{key}:\s*(.+?)\s*$", text, re.MULTILINE) + return match.group(1) if match else None + + +def active_host_usernames() -> set[str]: + usernames = set() + for path in PEOPLE_DIR.glob("*.md"): + text = path.read_text(encoding="utf-8") + if front_matter_field(text, "type") != "host": + continue + if front_matter_field(text, "is_archived") == "true": + continue + usernames.add(front_matter_field(text, "username")) + return usernames + + +@fixture(autouse=True) +def setup(page: Page): + """ + Code here will run for every test in this file + """ + page.goto("/people/") + + +def test_people_all_hosts(page: Page): + """ + Ensure the host list shows exactly the non-archived hosts in content/people + """ + expected = {f"/hosts/{username}" for username in active_host_usernames()} + assert expected + + host_links = page.locator(".host-list a") + expect(host_links).to_have_count(len(expected)) + actual = {link.get_attribute("href") for link in host_links.all()} + assert actual == expected + + +def test_people_guest_pagination(page: Page): + """ + Ensure page 2 of the people list shows different guests than page 1 + """ + first_guest = page.locator(".guest-list a").first + expect(first_guest).to_be_visible() + first_guest_page_1 = first_guest.get_attribute("href") + + response = page.goto("/people/page/2/") + assert response.status == 200 + first_guest = page.locator(".guest-list a").first + expect(first_guest).to_be_visible() + assert first_guest.get_attribute("href") != first_guest_page_1 diff --git a/test/e2e/test_person_pages.py b/test/e2e/test_person_pages.py new file mode 100644 index 000000000..c5527f36e --- /dev/null +++ b/test/e2e/test_person_pages.py @@ -0,0 +1,117 @@ +import json +import re +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + +from pytest import mark, param +from playwright.sync_api import APIRequestContext, Page, expect + +from episode_data import CONTENT, CONTENT_SHOW, META_FILES, load_episode + +GUEST_SAMPLE = 3 + + +@dataclass(frozen=True) +class Person: + username: str + type: str + title: str + archived: bool + + +def _field(text: str, key: str) -> str | None: + # flat YAML front matter; the test image has no YAML parser + match = re.search(rf"^{key}: *[\"']?(.+?)[\"']?\s*$", text, re.M) + return match.group(1) if match else None + + +def _load_person(path: Path) -> Person: + text = path.read_text() + return Person( + username=_field(text, "username") or path.stem, + type=_field(text, "type") or "", + title=_field(text, "title") or "", + archived=_field(text, "is_archived") == "true", + ) + + +PEOPLE: dict[str, Person] = {p.username: p for p in map(_load_person, sorted((CONTENT / "people").glob("*.md")))} +EPISODES = [load_episode(p) for p in sorted(CONTENT_SHOW.glob("*/*.md")) if p.name not in META_FILES] +HOSTED = Counter(h for e in EPISODES for h in set(e.data.get("hosts") or [])) +GUESTED = Counter(g for e in EPISODES for g in set(e.data.get("guests") or [])) +SHOW_HOSTS: dict[str, list[str]] = { + d.name: json.loads(m.group(1)) if (m := re.search(r"^hosts\s*=\s*(\[.*\])", (d / "_index.md").read_text(), re.M)) else [] + for d in sorted(CONTENT_SHOW.iterdir()) + if (d / "_index.md").exists() +} + + +# people with no episodes (so no taxonomy page) re-added by the people scraper; their removal is tracked +# on another branch. Named, not inferred, so any new episode-less person still fails. +NO_EPISODE_PEOPLE = {"jnsgruk", "joe", "joeres", "michaetunnell"} + + +def _link_param(person: Person): + marks = [] + if person.username in NO_EPISODE_PEOPLE: + marks = [mark.xfail(strict=True, reason="no episodes, so no taxonomy page (people scraper, fixed on another branch)")] + return param(person, id=person.username, marks=marks) + + +# who /people/ links to (themes/jb/layouts/people/list.html) +LISTED_PEOPLE = [ + p for p in PEOPLE.values() if p.type == "guest" or (p.type == "host" and not p.archived) +] +HOST_TERMS = sorted(u for u, p in PEOPLE.items() if p.type == "host" and not p.archived and HOSTED[u]) +SAMPLE_GUESTS = sorted( + (u for u, p in PEOPLE.items() if p.type == "guest" and GUESTED[u]), + key=lambda u: (-GUESTED[u], u), +)[:GUEST_SAMPLE] + + +@mark.parametrize("person", [_link_param(p) for p in LISTED_PEOPLE]) +def test_people_list_link_target(api_request_context: APIRequestContext, person: Person): + """ + Every host/guest card on /people/ links to a page that exists + """ + path = f"/{person.type}s/{person.username}/" + response = api_request_context.get(path) + assert response.status == 200, f"{path} returned {response.status}" + + +def _expect_person_page(page: Page, person: Person, shows: set[str], episodes: int): + expect(page.locator("h2.title").first).to_have_text(re.compile(rf"^\s*{person.type.title()} - {re.escape(person.title)}\s*$")) + expect(page.locator("h2.title", has_text=f"Shows with {person.title}")).to_have_count(1) + show_links = page.locator(".column.is-one-thirds .card a") + hrefs = {re.sub(r"^https?://[^/]+", "", h) for h in (show_links.nth(i).get_attribute("href") for i in range(show_links.count()))} + assert hrefs == {f"/show/{s}/" for s in shows} + expect(page.locator("h2.title", has_text=f"Latest Episodes with {person.title}")).to_have_count(1) + expect(page.locator(".section .card-episode")).to_have_count(episodes) + + +@mark.only_browser("chromium") +@mark.parametrize("username", HOST_TERMS) +def test_host_page(page: Page, username: str): + """ + Host page shows the person's name, the shows they host, and a card per hosted episode + """ + person = PEOPLE[username] + # shows list the host in _index.md; a host only on episodes falls back to those episodes' shows + shows = {s for s, hosts in SHOW_HOSTS.items() if username in hosts} or { + e.show for e in EPISODES if username in (e.data.get("hosts") or []) + } + page.goto(f"/hosts/{username}/") + _expect_person_page(page, person, shows, HOSTED[username]) + + +@mark.only_browser("chromium") +@mark.parametrize("username", SAMPLE_GUESTS) +def test_guest_page(page: Page, username: str): + """ + Guest page shows the person's name, the shows they've been on, and a card per episode + """ + person = PEOPLE[username] + shows = {e.show for e in EPISODES if username in (e.data.get("guests") or [])} + page.goto(f"/guests/{username}/") + _expect_person_page(page, person, shows, GUESTED[username]) diff --git a/test/e2e/test_show.py b/test/e2e/test_show.py new file mode 100644 index 000000000..e209b9d1f --- /dev/null +++ b/test/e2e/test_show.py @@ -0,0 +1,191 @@ +import json +import math +import re +from pathlib import Path + +from pytest import fixture, mark, param +from playwright.sync_api import Page, expect + +REPO_ROOT = Path(__file__).parents[2] +SHOW_DIR = REPO_ROOT / "content/show" +PEOPLE_DIR = REPO_ROOT / "content/people" + + +try: + from tomllib import loads as toml_loads +except ImportError: + # The jb_tests image runs Python 3.10 with no TOML library; the show front + # matter only uses tables and JSON-compatible scalars/arrays. + def toml_loads(text: str) -> dict: + root: dict = {} + table = root + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("["): + table = root + for part in line.strip("[]").split("."): + table = table.setdefault(part, {}) + continue + key, _, value = line.partition("=") + table[key.strip()] = json.loads(value.strip()) + return root + + +PAGINATE = int( + re.search(r"^paginate\s*=\s*(\d+)", (REPO_ROOT / "config.toml").read_text(), re.M).group(1) +) + + +def _front_matter(path: Path) -> dict: + match = re.match(r"\+\+\+\n(.*?)\n\+\+\+", path.read_text(), re.DOTALL) + return toml_loads(match.group(1)) + + +SHOWS = { + show_dir.name: _front_matter(show_dir / "_index.md") + for show_dir in sorted(SHOW_DIR.iterdir()) + if (show_dir / "_index.md").exists() +} +SHOWS_WITH_HOSTS = [slug for slug, fm in SHOWS.items() if "hosts" in fm] +HOST_PARAMS = [ + param( + slug, + id=slug, + marks=mark.xfail( + reason="content/people/michael.md has type: guest, so the Coder Radio host " + "list links him to /guests/michael/ instead of /hosts/michael/" + ), + ) + if slug == "coder-radio" + else param(slug, id=slug) + for slug in SHOWS_WITH_HOSTS +] + + +def _episode_count(slug: str) -> int: + return sum( + '"type": "episode"' in f.read_text() for f in (SHOW_DIR / slug).glob("*.md") + ) + + +@fixture +def show(page: Page, request) -> dict: + """ + Navigate to the parametrized show page and return its front matter + """ + slug = request.param + page.goto(f"/show/{slug}/") + return SHOWS[slug] + + +@mark.only_browser("chromium") +@mark.parametrize("show", HOST_PARAMS, indirect=True) +def test_show_hosts(page: Page, show: dict): + """ + Ensure every host in the show's front matter has a people page and is + rendered in the host list + """ + missing = [h for h in show["hosts"] if not (PEOPLE_DIR / f"{h}.md").exists()] + assert not missing, f"hosts without content/people/<username>.md: {missing}" + + links = page.locator('.host-list a[href^="/hosts/"]') + hrefs = {links.nth(i).get_attribute("href") for i in range(links.count())} + assert hrefs == {f"/hosts/{h}/" for h in show["hosts"]} + + +@mark.only_browser("chromium") +@mark.parametrize("show", SHOWS_WITH_HOSTS, ids=SHOWS_WITH_HOSTS, indirect=True) +def test_show_link_icons(page: Page, show: dict): + """ + Ensure the subscribe icon and one icon per front matter link are rendered + """ + level = page.locator(".level") + expect(level.locator('a.link[title="subscribe"]')).to_have_count(1) + for key, value in show.get("links", {}).items(): + link = level.locator(f'a.link[title="{key}"]') + expect(link).to_have_count(1) + expect(link).to_have_attribute("href", value["url"]) + expect(level.locator("a.link")).to_have_count(1 + len(show.get("links", {}))) + + +PAGINATED_SHOWS = [slug for slug in SHOWS if _episode_count(slug) > PAGINATE] + + +@mark.only_browser("chromium") +@mark.parametrize("show", PAGINATED_SHOWS, ids=PAGINATED_SHOWS, indirect=True) +def test_show_pagination(page: Page, show: dict, request): + """ + Ensure the show's episode pagination has the right page count and page 2 + loads different episodes + """ + slug = request.node.callspec.params["show"] + last_page = math.ceil(_episode_count(slug) / PAGINATE) + pagination = page.locator('.episode-list [aria-label="pagination"]') + expect(pagination.locator("a", has_text="Last Page")).to_have_attribute( + "href", re.compile(rf"/show/{slug}/page/{last_page}/$") + ) + + first_card = page.locator(".episode-list .card").nth(0).text_content() + pagination.locator(".pagination-link", has_text=re.compile(r"^\s*2\s*$")).click() + page.wait_for_load_state("networkidle") + assert f"/show/{slug}/page/2" in page.url + first_card_second_page = page.locator(".episode-list .card").nth(0).text_content() + assert first_card != first_card_second_page + + +def _show_card_links(page: Page) -> set[str]: + links = page.locator(".episode-list .card-episode a") + return {re.sub(r"^https?://[^/]+", "", links.nth(i).get_attribute("href")) for i in range(links.count())} + + +@mark.only_browser("chromium") +def test_show_list_active_shows(page: Page): + """ + /show/ lists exactly the active shows + """ + page.goto("/show/") + assert _show_card_links(page) == {f"/show/{slug}/" for slug, fm in SHOWS.items() if fm.get("active")} + + +@mark.only_browser("chromium") +def test_archive_lists_archived_shows(page: Page): + """ + /archive/ lists exactly the shows that are no longer active + """ + page.goto("/archive/") + assert _show_card_links(page) == {f"/show/{slug}/" for slug, fm in SHOWS.items() if not fm.get("active")} + + +def _subscribe_front_matter(slug: str) -> tuple[str, dict[str, str]]: + # flat YAML with one nested map; the test image has no YAML parser + text = (SHOW_DIR / slug / "subscribe.md").read_text() + rss = re.search(r'^rss:\s*"?([^"\s]+)"?', text, re.M).group(1) + block = re.search(r"^direct_links:\n((?:[ \t]+.*\n)*)", text, re.M) + links = dict(re.findall(r"^[ \t]+(\w+):\s*(\S+)", block.group(1), re.M)) if block else {} + return rss, links + + +SUBSCRIBE_SHOWS = [slug for slug in SHOWS if (SHOW_DIR / slug / "subscribe.md").exists()] +PODCAST_PLAYER_ICONS: dict[str, str] = json.loads((REPO_ROOT / "data/podcast_player.json").read_text()) + + +@mark.only_browser("chromium") +@mark.parametrize("slug", SUBSCRIBE_SHOWS) +def test_subscribe_page(page: Page, slug: str): + """ + Subscribe page has the show's RSS button and one icon link per direct_links entry in subscribe.md + """ + rss, direct_links = _subscribe_front_matter(slug) + page.goto(f"/show/{slug}/subscribe/") + expect(page.locator("a.button", has_text="RSS Feed")).to_have_attribute("href", rss) + + unknown = set(direct_links) - set(PODCAST_PLAYER_ICONS) + assert not unknown, f"direct_links keys without an icon in data/podcast_player.json: {unknown}" + icons = page.locator(".subscribe-logos a") + expect(icons).to_have_count(len(direct_links)) + for key, url in direct_links.items(): + link = page.locator(f'.subscribe-logos a:has(img[alt="{key} subscription link"])') + expect(link).to_have_attribute("href", url) + expect(link.locator("img")).to_have_attribute("src", PODCAST_PLAYER_ICONS[key]) diff --git a/test/e2e/test_sponsors.py b/test/e2e/test_sponsors.py new file mode 100644 index 000000000..93f2751d4 --- /dev/null +++ b/test/e2e/test_sponsors.py @@ -0,0 +1,91 @@ +import json +import re +from datetime import datetime, timedelta, timezone + +from pytest import mark +from playwright.sync_api import Page + +from episode_data import CONTENT, CONTENT_SHOW, META_FILES, load_episode + +REPO_ROOT = CONTENT.parent +LOOKBACK_DAYS = -int( + re.search(r"^sponsors\.lookbackdays\s*=\s*(-?\d+)", (REPO_ROOT / "config/_default/params.toml").read_text(), re.M).group(1) +) +# the site may have been built a while before the tests run, so episodes near the cutoff may go either way +CUTOFF_SLACK = timedelta(days=2) + + +def _sponsor_links() -> dict[str, str]: + links = {} + for path in (CONTENT / "sponsors").glob("*.md"): + text = path.read_text() + shortname = re.search(r"^shortname:\s*(\S+)", text, re.M) + link = re.search(r"^link:\s*(\S+)", text, re.M) + if shortname and link: + links[shortname.group(1)] = link.group(1) + return links + + +SPONSOR_LINKS = _sponsor_links() + + +def _toml_list(text: str, key: str) -> list[str]: + match = re.search(rf"^{key}\s*=\s*(\[.*\])", text, re.M) + return json.loads(match.group(1)) if match else [] + + +def _published(date: str) -> datetime: + parsed = datetime.fromisoformat(date) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _expected_sections(cutoff: datetime) -> dict[str, set[str]]: + """ + heading -> sponsor links, per themes/jb/layouts/sponsors/list.html for a build at cutoff + LOOKBACK_DAYS + """ + sections = {"Jupiter Broadcasting": set(_toml_list((CONTENT / "sponsors/_index.md").read_text(), "sponsors"))} + for show_dir in sorted(p for p in CONTENT_SHOW.iterdir() if (p / "_index.md").exists()): + index = (show_dir / "_index.md").read_text() + recent = { + sponsor + for path in show_dir.glob("*.md") + if path.name not in META_FILES + for episode in [load_episode(path)] + if _published(episode.data["date"]) >= cutoff + for sponsor in episode.data.get("sponsors") or [] + } + if recent: + title = re.search(r'^title\s*=\s*"(.*)"', index, re.M).group(1) + sections[title] = recent | set(_toml_list(index, "sponsors")) + return {title: {SPONSOR_LINKS[s] for s in sponsors if s in SPONSOR_LINKS} for title, sponsors in sections.items()} + + +def _rendered_sections(page: Page) -> dict[str, set[str]]: + return page.evaluate( + """() => { + const sections = {}; + let current = null; + for (const el of document.querySelectorAll('.spnosor-list .content > *')) { + if (el.tagName === 'H2') sections[current = el.textContent.trim()] = []; + else if (current) el.querySelectorAll('a[href]').forEach(a => sections[current].push(a.getAttribute('href'))); + } + return sections; + }""" + ) + + +@mark.only_browser("chromium") +def test_sponsors_page(page: Page): + """ + /sponsors/ lists the network sponsors, then each show with sponsors on an episode in the + last LOOKBACK_DAYS days (plus the show's own front matter sponsors) + """ + page.goto("/sponsors/") + rendered = {title: set(links) for title, links in _rendered_sections(page).items()} + cutoff = datetime.now(timezone.utc) - timedelta(days=LOOKBACK_DAYS) + required = _expected_sections(cutoff + CUTOFF_SLACK) + allowed = _expected_sections(cutoff - CUTOFF_SLACK) + + assert set(required) <= set(rendered) <= set(allowed), f"sections: {sorted(rendered)}" + for title, links in rendered.items(): + assert required.get(title, set()) <= links <= allowed[title], f"{title}: unexpected sponsor links" diff --git a/test/e2e/test_tags.py b/test/e2e/test_tags.py new file mode 100644 index 000000000..c0a2b1d97 --- /dev/null +++ b/test/e2e/test_tags.py @@ -0,0 +1,134 @@ +import html +import re +import unicodedata +from collections import defaultdict +from functools import cache +from typing import Callable + +from pytest import mark, param +from playwright.sync_api import APIRequestContext, Page + +from episode_data import CONTENT_SHOW, META_FILES, load_episode + +HOST = r"(?:https?://[^/\"]+)?" + + +def _episodes_by_tag() -> dict[str, list[str]]: + tags: dict[str, list[str]] = defaultdict(list) + for path in sorted(CONTENT_SHOW.glob("*/*.md")): + if path.name in META_FILES: + continue + episode = load_episode(path) + for tag in episode.data.get("tags") or []: + tags[tag].append(episode.url) + return tags + + +EPISODES_BY_TAG = _episodes_by_tag() + +DOUBLE_HYPHEN = mark.xfail( + reason="partials/episode/tags.html builds the URL by hand (spaces to hyphens, then urlize), " + "which leaves a double hyphen where Hugo 0.105's term permalink has one", +) +HASH = mark.xfail(reason="partials/episode/tags.html leaves '#' unescaped, so the link ends at a URL fragment") +EMOJI = mark.skip(reason="Issue #516: emoji-only tags get no tag page") + +# (category, matches, marks for /tags/ index test, marks for the link from an episode page) +CATEGORIES: list[tuple[str, Callable[[str], bool], list, list]] = [ + ("space", lambda t: re.fullmatch(r"[a-z0-9]+( [a-z0-9]+)+", t) is not None, [], []), + ("apostrophe", lambda t: "'" in t, [], []), + ("ampersand", lambda t: " & " in t, [], [DOUBLE_HYPHEN]), + ("space-hyphen", lambda t: " -" in t, [], [DOUBLE_HYPHEN]), + ("plus", lambda t: "+" in t and "#" not in t, [], []), + ("hash", lambda t: "#" in t, [], [HASH]), + ("dot", lambda t: "." in t, [], []), + ("curly-quote", lambda t: any(c in "‘’“”" for c in t), [], []), + ("emoji", lambda t: any(unicodedata.category(c) == "So" for c in t), [EMOJI], [EMOJI]), +] + + +def _tag_params(marks_index: int) -> list: + params = [] + for category in CATEGORIES: + name, matches = category[:2] + tag = next((t for t in sorted(EPISODES_BY_TAG) if matches(t)), None) + if tag is not None: + params.append(param(tag, id=name, marks=category[marks_index])) + return params + + +INDEX_PARAMS = _tag_params(2) +LINK_PARAMS = _tag_params(3) + + +@cache +def _tags_index(api: APIRequestContext) -> dict[str, tuple[str, int]]: + """ + tag title -> (href, count) from /tags/ + """ + response = api.get("/tags/") + assert response.ok, f"/tags/ returned {response.status}" + return { + html.unescape(title): (html.unescape(href), int(count)) + for href, title, count in re.findall( + r"<a href='([^']+)'>(.*?) \((\d+)\)</a>", response.text() + ) + } + + +def _tag_page_lists(api: APIRequestContext, href: str, urls: list[str]) -> list[str]: + response = api.get(href) + assert response.status == 200, f"{href} returned {response.status}" + text = response.text() + return [url for url in urls if not re.search(rf'href="{HOST}{re.escape(url)}"', text)] + + +def test_tag_categories_found(): + """ + Every edge-case category still has a tag in content, so the parametrized tests below aren't silently empty + """ + expected = {category[0] for category in CATEGORIES} + found = {p.id for p in INDEX_PARAMS} + assert found == expected, f"no tag in content for: {expected - found}" + + +@mark.parametrize("tag", INDEX_PARAMS) +def test_tags_index_entry(api_request_context: APIRequestContext, tag: str): + """ + /tags/ lists the tag with at least as many episodes as carry it in content, and its link + leads to a tag page listing those episodes + """ + index = _tags_index(api_request_context) + assert tag in index, f"{tag!r} missing from /tags/" + href, count = index[tag] + episodes = EPISODES_BY_TAG[tag] + # tags differing only in spaces/hyphens share a page, so the count can be higher + assert count >= len(episodes), f"/tags/ count {count} < {len(episodes)} episodes in content" + missing = _tag_page_lists(api_request_context, href, episodes) + assert not missing, f"episodes missing from {href}: {missing}" + + +@mark.only_browser("chromium") +@mark.parametrize("tag", LINK_PARAMS) +def test_tag_link_from_episode(page: Page, api_request_context: APIRequestContext, tag: str): + """ + The tag link on an episode page leads to a tag page that lists the episode + """ + episode = EPISODES_BY_TAG[tag][0] + page.goto(episode, wait_until="domcontentloaded") + links = page.locator(".tags .tag a").filter(has_text=re.compile(rf"^\s*{re.escape(tag)}\s*$")) + assert links.count() == 1, f"{episode} should show tag {tag!r} once" + href = links.first.get_attribute("href") + missing = _tag_page_lists(api_request_context, href, [episode]) + assert not missing, f"{href} doesn't list {episode}" + + +@mark.only_browser("chromium") +def test_tag_page_heading(page: Page): + """ + A tag page titles itself with the tag and lists every episode carrying it as a card + """ + tag, episodes = next((t, e) for t, e in sorted(EPISODES_BY_TAG.items()) if re.fullmatch(r"[a-z]+", t) and len(e) > 1) + page.goto(f"/tags/{tag}/") + assert page.locator("h1.title").inner_text().split() == ["Tag:", tag] + assert page.locator(".card-episode").count() >= len(episodes) diff --git a/test/external/test_matrix.py b/test/external/test_matrix.py index 4a7e10271..bfe01b180 100644 --- a/test/external/test_matrix.py +++ b/test/external/test_matrix.py @@ -4,7 +4,12 @@ @mark.periodic def test_matrix_federation(api_request_context: APIRequestContext): - response = api_request_context.get("https://federationtester.matrix.org/api/report?server_name=jupiterbroadcasting.com") + """ + Call the matrix federation API to make sure the JB federation is working. + """ + response = api_request_context.get( + "https://federationtester.matrix.org/api/report?server_name=jupiterbroadcasting.com" + ) assert response.ok, "API did not return a response code of 200-299" assert response.body() != "", "API did not return any data" - assert response.json()["FederationOK"] == True, "Federation is not okay" + assert response.json()["FederationOK"] is True, "Federation is not okay" diff --git a/test/fixture_files/jb-live_sample-live-event.json b/test/fixture_files/jb-live_sample-live-event.json deleted file mode 100644 index 967163867..000000000 --- a/test/fixture_files/jb-live_sample-live-event.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "total": 1, - "data": [ - { - "id": 50, - "uuid": "55b2e9d1-ccf3-4914-9f3f-c5d8a59bd4c1", - "shortUUID": "bzMpxgG2rDKn9C99GSab92", - "url": "https://jupiter.tube/videos/watch/55b2e9d1-ccf3-4914-9f3f-c5d8a59bd4c1", - "name": "LINUX Unplugged 471 - We Broke Our Server", - "category": { - "id": 15, - "label": "Science & Technology" - }, - "licence": { - "id": 5, - "label": "Attribution - Non Commercial - Share Alike" - }, - "language": { - "id": "en", - "label": "English" - }, - "privacy": { - "id": 1, - "label": "Public" - }, - "nsfw": false, - "description": "We broke our server, so lets see if we can fix it live before the show is over!", - "isLocal": true, - "duration": 0, - "views": 0, - "viewers": 19, - "likes": 0, - "dislikes": 0, - "thumbnailPath": "/static/thumbnails/b610e5f5-bbb5-4ee1-ac24-421c50f0daf5.jpg", - "previewPath": "/lazy-static/previews/fcd582d8-68c5-4f00-8a4a-0a8ffde1a4a5.jpg", - "embedPath": "/videos/embed/55b2e9d1-ccf3-4914-9f3f-c5d8a59bd4c1", - "createdAt": "2022-08-14T16:26:27.938Z", - "updatedAt": "2022-08-14T18:53:15.544Z", - "publishedAt": "2022-08-14T18:53:15.543Z", - "originallyPublishedAt": null, - "isLive": true, - "account": { - "id": 3, - "displayName": "JBLive Stream", - "name": "jblive", - "url": "https://jupiter.tube/accounts/jblive", - "host": "jupiter.tube", - "avatars": [ - { - "width": 48, - "path": "/lazy-static/avatars/e4d17e7f-a144-4a4a-b5d8-b297b06b727f.png", - "createdAt": "2022-06-07T23:43:56.565Z", - "updatedAt": "2022-06-07T23:43:56.565Z" - }, - { - "width": 120, - "path": "/lazy-static/avatars/1340f3e9-d0a7-4bc4-bcbd-a8c865eaf1b8.png", - "createdAt": "2022-05-30T20:36:19.005Z", - "updatedAt": "2022-05-30T20:36:19.005Z" - } - ], - "avatar": { - "width": 48, - "path": "/lazy-static/avatars/e4d17e7f-a144-4a4a-b5d8-b297b06b727f.png", - "createdAt": "2022-06-07T23:43:56.565Z", - "updatedAt": "2022-06-07T23:43:56.565Z" - } - }, - "channel": { - "id": 2, - "name": "live", - "displayName": "live", - "url": "https://jupiter.tube/video-channels/live", - "host": "jupiter.tube", - "avatars": [ - { - "width": 48, - "path": "/lazy-static/avatars/8acfd2a2-ab4e-48aa-990c-3156a2765d2e.png", - "createdAt": "2022-06-07T23:43:56.607Z", - "updatedAt": "2022-06-07T23:43:56.607Z" - }, - { - "width": 120, - "path": "/lazy-static/avatars/e9bcfdb7-90a2-479c-b1da-1ab0e5fc9442.png", - "createdAt": "2022-05-30T20:38:33.632Z", - "updatedAt": "2022-05-30T20:38:33.632Z" - } - ], - "avatar": { - "width": 48, - "path": "/lazy-static/avatars/8acfd2a2-ab4e-48aa-990c-3156a2765d2e.png", - "createdAt": "2022-06-07T23:43:56.607Z", - "updatedAt": "2022-06-07T23:43:56.607Z" - } - } - } - ] -} diff --git a/themes/jb/assets/css/main.sass b/themes/jb/assets/css/main.sass index 4ca59a41b..62f8b7bff 100644 --- a/themes/jb/assets/css/main.sass +++ b/themes/jb/assets/css/main.sass @@ -15,7 +15,8 @@ body min-height: 100dvh .host-list .columns - @include until(600px) + // below 376px two cards don't fit next to each other, so Bulma's one-per-row is-12-mobile applies + @media screen and (min-width: 376px) and (max-width: 599px) display: grid grid-template-columns: repeat(2, 1fr) .column:first-child:last-child diff --git a/themes/jb/assets/js/live.js b/themes/jb/assets/js/live.js index 3f14d5ab0..081a77eeb 100644 --- a/themes/jb/assets/js/live.js +++ b/themes/jb/assets/js/live.js @@ -118,7 +118,6 @@ document.addEventListener("DOMContentLoaded", function () { audioPlayer.play().catch(function (e) { console.warn("Autoplay prevented:", e); }); toggleButton.textContent = "Switch to Video"; } - toggleButton.setAttribute("aria-checked", currentMode == 'audio'); } if (toggleButton) { diff --git a/themes/jb/assets/js/main.js b/themes/jb/assets/js/main.js index 186c1d27c..2a0d56910 100644 --- a/themes/jb/assets/js/main.js +++ b/themes/jb/assets/js/main.js @@ -3,6 +3,9 @@ const swiper = new Swiper(".show-slider", { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev', }, + // a held drag (> longSwipesMs) must cross this fraction of a whole group (up to 3 slides), but with only a + // few shows the slider can scroll far less than that, so the default 0.5 springs back at most widths + longSwipesRatio: 0.1, slidesPerGroup: 1, slidesPerView: 1.1, spaceBetween: 12, diff --git a/themes/jb/assets/js/navigation.js b/themes/jb/assets/js/navigation.js index 69113d613..7e21da9ac 100644 --- a/themes/jb/assets/js/navigation.js +++ b/themes/jb/assets/js/navigation.js @@ -17,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => { // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" el.classList.toggle('is-active'); $target.classList.toggle('is-active'); + el.setAttribute('aria-expanded', el.classList.contains('is-active')); }); }); diff --git a/themes/jb/layouts/404.html b/themes/jb/layouts/404.html index 38d13d8a1..627cfed32 100644 --- a/themes/jb/layouts/404.html +++ b/themes/jb/layouts/404.html @@ -1,6 +1,6 @@ {{ define "main"}} <section class="hero is-large"> <p class="title has-text-centered mb-3">404 Page Not Found</p> - <img src="images/backgrounds/404-unicorn.jpg" alt="Unicorn with broken machine and wires"/> + <img id="img-404" src="images/backgrounds/404-unicorn.jpg" alt="Unicorn with broken machine and wires"/> </section> {{ end }} \ No newline at end of file diff --git a/themes/jb/layouts/live/list.html b/themes/jb/layouts/live/list.html index 3ae6ef8e3..09a93dbfb 100644 --- a/themes/jb/layouts/live/list.html +++ b/themes/jb/layouts/live/list.html @@ -25,7 +25,7 @@ <h1>{{ .Title }}</h1> </div> </div> </div> - <button id="toggle-button" class="button is-info" role="switch" aria-checked="false">Switch to Audio</button> + <button id="toggle-button" class="button is-info">Switch to Audio</button> </div> <div class="container">