From 64f757a6e8fc9130c8e2147ec05d6e82c60ab2c2 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Wed, 26 Oct 2022 04:02:25 -0400 Subject: [PATCH 01/36] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20standardize=20test?= =?UTF-8?q?=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/e2e/test_contact.py | 12 ++++++++++-- test/e2e/test_home.py | 32 ++++++++++++++++++++++++++++---- test/e2e/test_infra_configs.py | 4 ++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/test/e2e/test_contact.py b/test/e2e/test_contact.py index 0986e559a..50a49d8f6 100644 --- a/test/e2e/test_contact.py +++ b/test/e2e/test_contact.py @@ -1,11 +1,20 @@ from pathlib import Path +from pytest import fixture from playwright.sync_api import Page, FrameLocator, Locator + +""" +Code here will run for every test in this file +""" +@fixture(autouse=True) +def setup(page: Page): + page.goto("/contact") + + """ 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) """ @@ -20,7 +29,6 @@ def test_contact_screenshot(page: Page, screenshot_dir: Path): 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): - page.goto("/contact") contact_form: FrameLocator = page.frame_locator('#contact-frame') submit_button: Locator = contact_form.locator("#saveForm") diff --git a/test/e2e/test_home.py b/test/e2e/test_home.py index 3169f78d4..dbfb7c556 100644 --- a/test/e2e/test_home.py +++ b/test/e2e/test_home.py @@ -5,15 +5,25 @@ 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 + +""" +Code here will run for every test in this file +""" @fixture(autouse=True) def setup(page: Page): page.goto("/") + +""" +Take a screenshot of the home page for manual review +""" def test_homepage_screenshot(page: Page, screenshot_dir: Path): page.screenshot(path=f"{screenshot_dir}/home.png", full_page=True) + +""" +Ensure the large logo and text underneath it is visible on the screen +""" def test_homepage_has_logo(page: Page): logo = page.locator(".logo") expect(logo).to_be_visible() @@ -21,6 +31,10 @@ def test_homepage_has_logo(page: Page): 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') + +""" +Ensure the pagination loads different episodes +""" def test_pagination(page: Page): first_card = page.locator('.card').nth(0).text_content page_2_button = page.locator('[aria-label="pagination"] >> text=2') @@ -30,15 +44,23 @@ def test_pagination(page: Page): first_card_second_page = page.locator('.card').nth(0).text_content assert first_card != first_card_second_page + +""" +Ensure the RSS feeds menu contains all the fields specified in the conftest file +""" 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']) +""" +Tests dropdowns to make sure the dropdown items have links, and all items specified in conftest are present +""" +@mark.dev def test_dropdowns(page: Page, expected_dropdown_items: Dict[str,List[Dict[str,str]]]): for dropdown_text, child_elements in expected_dropdown_items.items(): - + # dropdown item to hover over in menu parent_element: Locator = page.locator(f'.navbar-start >> a:has-text("{dropdown_text}"):visible') @@ -65,7 +87,9 @@ def test_dropdowns(page: Page, expected_dropdown_items: Dict[str,List[Dict[str,s expect(item).to_be_visible() - +""" +Test main navigation items +""" def test_nav(page: Page, expected_dropdowns, expect_nav_items): nav = page.locator('#mainnavigation') diff --git a/test/e2e/test_infra_configs.py b/test/e2e/test_infra_configs.py index fdddac41d..645828bd6 100644 --- a/test/e2e/test_infra_configs.py +++ b/test/e2e/test_infra_configs.py @@ -1,6 +1,10 @@ from playwright.sync_api import APIRequestContext +""" +Check to make sure the files required for matrix federation and name resolution +are present and contain necessary details +""" def test_matrix_well_known( api_request_context: APIRequestContext, ): From 0c885cec00051a861e57774891c1cd7d7412999c Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Wed, 26 Oct 2022 04:02:52 -0400 Subject: [PATCH 02/36] =?UTF-8?q?=E2=9C=85=20Add=20test=20for=20404=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/e2e/test_404.py | 29 +++++++++++++++++++++++++++++ themes/jb/layouts/404.html | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 test/e2e/test_404.py diff --git a/test/e2e/test_404.py b/test/e2e/test_404.py new file mode 100644 index 000000000..185ceeec9 --- /dev/null +++ b/test/e2e/test_404.py @@ -0,0 +1,29 @@ +from pathlib import Path +from pytest import fixture +from playwright.sync_api import Page, expect + + +""" +Code here will run for every test in this file +""" +@fixture(autouse=True) +def setup(page: Page): + page.goto("/404.html") + + +""" +Take a screenshot of the 404 page for manual review +""" +def test_404_screenshot(page: Page, screenshot_dir: Path): + page.screenshot(path=f"{screenshot_dir}/404.png", full_page=True) + + +""" +Ensure text "Page Not Found" and unicorn image is present on the 404 page +""" +def test_404_contents(page: 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/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"}}

404 Page Not Found

- Unicorn with broken machine and wires + Unicorn with broken machine and wires
{{ end }} \ No newline at end of file From fc091087500ab8095def1b03e0defd3b02935690 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Fri, 4 Nov 2022 20:10:37 -0400 Subject: [PATCH 03/36] :white_check_mark: Add calendar screenshot test --- test/e2e/test_calendar.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 test/e2e/test_calendar.py diff --git a/test/e2e/test_calendar.py b/test/e2e/test_calendar.py new file mode 100644 index 000000000..8d43f3bbf --- /dev/null +++ b/test/e2e/test_calendar.py @@ -0,0 +1,20 @@ +from pathlib import Path +from pytest import fixture +from playwright.sync_api import Page, expect, FrameLocator, Locator + + +""" +Code here will run for every test in this file +""" +@fixture(autouse=True) +def setup(page: Page): + page.goto("/calendar") + page.wait_for_load_state("networkidle") + + +""" +Take a screenshot of the calendar page for manual review +""" +def test_calendar_screenshot(page: Page, screenshot_dir: Path): + page.screenshot(path=f"{screenshot_dir}/calendar.png", full_page=True) + From 2fc53f6e0717b7c2eadf0f0428ed75c9ac3fffcb Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Fri, 4 Nov 2022 20:47:27 -0400 Subject: [PATCH 04/36] :white_check_mark: add episode url generater and test for tab text --- test/e2e/test_episode.py | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 test/e2e/test_episode.py diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py new file mode 100644 index 000000000..4e13880db --- /dev/null +++ b/test/e2e/test_episode.py @@ -0,0 +1,54 @@ +import os +import re + +from typing import List +from pathlib import Path +from pytest import fixture, mark +from playwright.sync_api import Page, expect + +""" +List of episodes that contain known edge cases +""" +extra_episodes: List[dict] = [ + { + "show": "linux-unplugged", + "number": "434" + } +] + +""" +Generate list of episode URLS for testing. This list consists of the following +* For each show: + * 5 oldest episodes + * 5 newest episodes +* A speified set of episodes that contain "out of the ordinary" things for an episode (an example being a high number of guests +""" +@fixture() +def episodes() -> List[str]: + test_episodes: List[str] = [] + shows_dir: str = "content/show" + meta_files: [str] = ["_index.md", "subscribe.md"] + + # Fist and last 5 episodes + for show in os.listdir(shows_dir): + show_url = f"/show/{show}" + episodes = [e for e in os.listdir(f"{shows_dir}/{show}") if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files] + episodes.sort() + for e in episodes[:5] : test_episodes.append(f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}") + for e in episodes[-5:] : test_episodes.append(f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}") + + # extra episodes that are good for testing + for episode in extra_episodes: + test_episodes.append(f"/show/{episode['show']}/{episode['number']}") + + return test_episodes + + +""" +Make sure the title (text in the tab) contains the show name, episode number, and network name (Jupiter Broadcasting) +""" +def test_episode_page_title(page: Page, episodes: List[str]): + for url in episodes: + _, show, episode_number = url.partition('/') + page.goto(url) + expect(page).to_have_title(re.compile(f".* | {show} {episode_number} | Jupiter Broadcasting")) From bbc6389348f29d5aa71e3e900541f6b0400e8b8d Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Wed, 9 Nov 2022 11:53:57 -0500 Subject: [PATCH 05/36] :memo: improve docs and typing for completed tests --- test/e2e/test_contact.py | 3 ++- test/external/test_matrix.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/test_contact.py b/test/e2e/test_contact.py index 50a49d8f6..51fc60f5f 100644 --- a/test/e2e/test_contact.py +++ b/test/e2e/test_contact.py @@ -1,3 +1,4 @@ +from typing import Dict from pathlib import Path from pytest import fixture from playwright.sync_api import Page, FrameLocator, Locator @@ -42,7 +43,7 @@ def test_submit_button_visible(page: Page, screenshot_dir: Path): # 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 - button_location: dict = submit_button.bounding_box() + 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") diff --git a/test/external/test_matrix.py b/test/external/test_matrix.py index 4a7e10271..56195b1c0 100644 --- a/test/external/test_matrix.py +++ b/test/external/test_matrix.py @@ -2,6 +2,9 @@ from playwright.sync_api import APIRequestContext +""" +Call the matrix federation API to make sure the JB federation is working. +""" @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") From 8a3689ea82546ac39ef48834395dbdc499fabb9c Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Wed, 9 Nov 2022 11:54:35 -0500 Subject: [PATCH 06/36] :test_tube: Start podverse player test --- test/e2e/test_episode.py | 73 +++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py index 4e13880db..8f7747c5e 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -1,10 +1,12 @@ import os import re -from typing import List +from functools import wraps +from typing import List, Any from pathlib import Path from pytest import fixture, mark -from playwright.sync_api import Page, expect +from playwright.sync_api import Page, FrameLocator, Locator, expect + """ List of episodes that contain known edge cases @@ -13,9 +15,40 @@ { "show": "linux-unplugged", "number": "434" + }, + { + "show": "coder-radio", + "number": "343" + }, + { + "show": "self-hosted", + "number": "59" } ] +""" +Cache decorator to reduce repeat computations. Basically if allows long functions to only need to runonly once with a certain set +of parameters. Speed increases are only noticed if the there are no parameters in the function or if the same parameters are being +inputted multiple times. + +How it works: +* Check if the cache dictionary has an entry for the function called +* If it doesn't find anything, it runs the function and adds it to the cache. +* If the result is in the cahce, return it instead of running it again +""" +def cache(func) -> Any: + cache = {} + + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + key = str(args) + str(kwargs) + if key not in cache: + cache[key] = func(*args, **kwargs) + return cache[key] + + return wrapper + + """ Generate list of episode URLS for testing. This list consists of the following * For each show: @@ -23,6 +56,7 @@ * 5 newest episodes * A speified set of episodes that contain "out of the ordinary" things for an episode (an example being a high number of guests """ +@cache @fixture() def episodes() -> List[str]: test_episodes: List[str] = [] @@ -31,13 +65,14 @@ def episodes() -> List[str]: # Fist and last 5 episodes for show in os.listdir(shows_dir): - show_url = f"/show/{show}" - episodes = [e for e in os.listdir(f"{shows_dir}/{show}") if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files] + show_url: str = f"/show/{show}" + episodes: List[str] = [e for e in os.listdir(f"{shows_dir}/{show}") if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files] episodes.sort() + # TODO: Check if episode is already in list of urls to test for e in episodes[:5] : test_episodes.append(f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}") for e in episodes[-5:] : test_episodes.append(f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}") - # extra episodes that are good for testing + # extra episodes for episode in extra_episodes: test_episodes.append(f"/show/{episode['show']}/{episode['number']}") @@ -47,8 +82,30 @@ def episodes() -> List[str]: """ Make sure the title (text in the tab) contains the show name, episode number, and network name (Jupiter Broadcasting) """ -def test_episode_page_title(page: Page, episodes: List[str]): - for url in episodes: - _, show, episode_number = url.partition('/') +def test_page_title(page: Page, episodes: List[str]): + # TODO: See if part of this can be moved to a fixture + def check_title(page: Page, url: str): + show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) + print(f"{show} - {episode_number}") expect(page).to_have_title(re.compile(f".* | {show} {episode_number} | Jupiter Broadcasting")) + + # TODO figure out how to call this loop async + for url in episodes: + check_title(page, url) + +""" +Test podverse player to and make sure the correct show is displayed +""" +def test_podverse_player(page: Page, episodes: List[str]): + def check_title(page:Page, url: str): + show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url + page.goto(url) + pv_player: FrameLocator = page.frame_locator("#pv-embed-player") + # expect(pv_player).to_be_visible() + pv_player_show_text: Locator = pv_player.locator(".embed-player-header-top-text").text_content() + assert pv_player_show_text.replace('-', ' ').lower() == show.replace('-', ' ').lower(), f"Podverse player show for {url} says {pv_player_show_text}" + + # TODO figure out how to call this loop async + for url in episodes: + check_title(page, url) From fd6e8eafd44be02c5026e994b376ba19bef0db16 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Sat, 21 Jan 2023 20:52:02 -0500 Subject: [PATCH 07/36] :construction: Work on episode list generation --- test/e2e/test_404.py | 2 +- test/e2e/test_episode.py | 74 ++++++++++++---------------------------- 2 files changed, 22 insertions(+), 54 deletions(-) diff --git a/test/e2e/test_404.py b/test/e2e/test_404.py index 185ceeec9..9dc663cdd 100644 --- a/test/e2e/test_404.py +++ b/test/e2e/test_404.py @@ -8,7 +8,7 @@ """ @fixture(autouse=True) def setup(page: Page): - page.goto("/404.html") + page.goto("/404.html") # TODO: Change to random address once #483 is merged """ diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py index 8f7747c5e..6a0a90d44 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -2,79 +2,50 @@ import re from functools import wraps -from typing import List, Any +from typing import Any from pathlib import Path from pytest import fixture, mark from playwright.sync_api import Page, FrameLocator, Locator, expect """ -List of episodes that contain known edge cases +Dictionary of episodes that contain known edge cases """ -extra_episodes: List[dict] = [ - { +EXTRA_EPISODES: list[dict[str, str]] = [ + { # 3 Hosts and 3 Guests "show": "linux-unplugged", "number": "434" }, - { + { # No Sponsor "show": "coder-radio", "number": "343" - }, - { - "show": "self-hosted", - "number": "59" } ] -""" -Cache decorator to reduce repeat computations. Basically if allows long functions to only need to runonly once with a certain set -of parameters. Speed increases are only noticed if the there are no parameters in the function or if the same parameters are being -inputted multiple times. - -How it works: -* Check if the cache dictionary has an entry for the function called -* If it doesn't find anything, it runs the function and adds it to the cache. -* If the result is in the cahce, return it instead of running it again -""" -def cache(func) -> Any: - cache = {} - - @wraps(func) - def wrapper(*args, **kwargs) -> Any: - key = str(args) + str(kwargs) - if key not in cache: - cache[key] = func(*args, **kwargs) - return cache[key] - - return wrapper - - """ Generate list of episode URLS for testing. This list consists of the following * For each show: * 5 oldest episodes * 5 newest episodes -* A speified set of episodes that contain "out of the ordinary" things for an episode (an example being a high number of guests +* A specified set of episodes that contain "out of the ordinary" things for an episode (an example being a high number of guests """ -@cache @fixture() -def episodes() -> List[str]: - test_episodes: List[str] = [] +def episodes() -> list[str]: + test_episodes: list[str] = [] shows_dir: str = "content/show" meta_files: [str] = ["_index.md", "subscribe.md"] - # Fist and last 5 episodes + # Fist and last 5 episodes for each show for show in os.listdir(shows_dir): show_url: str = f"/show/{show}" - episodes: List[str] = [e for e in os.listdir(f"{shows_dir}/{show}") if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files] - episodes.sort() - # TODO: Check if episode is already in list of urls to test - for e in episodes[:5] : test_episodes.append(f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}") - for e in episodes[-5:] : test_episodes.append(f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}") + all_episodes: list[str] = [e for e in os.listdir(f"{shows_dir}/{show}") if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files] + all_episodes.sort() - # extra episodes - for episode in extra_episodes: - test_episodes.append(f"/show/{episode['show']}/{episode['number']}") + test_episodes += [f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" for e in all_episodes[:5:] if e not in test_episodes] # First 5 episodes of a show + test_episodes += [f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" for e in all_episodes[-5::] if e not in test_episodes] # 5 most recent episodes of a show + + # Defined list of edge case episodes + test_episodes += [f"/show/{e['show']}/{e['number']}" for e in EXTRA_EPISODES if e not in test_episodes] # Extra episodes worth testing return test_episodes @@ -82,22 +53,19 @@ def episodes() -> List[str]: """ Make sure the title (text in the tab) contains the show name, episode number, and network name (Jupiter Broadcasting) """ -def test_page_title(page: Page, episodes: List[str]): - # TODO: See if part of this can be moved to a fixture - def check_title(page: Page, url: str): +@mark.dev +def test_page_title(page: Page, episodes: list[str]): + # TODO figure out how to call this loop async + for url in episodes: show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) print(f"{show} - {episode_number}") expect(page).to_have_title(re.compile(f".* | {show} {episode_number} | Jupiter Broadcasting")) - # TODO figure out how to call this loop async - for url in episodes: - check_title(page, url) - """ Test podverse player to and make sure the correct show is displayed """ -def test_podverse_player(page: Page, episodes: List[str]): +def test_podverse_player(page: Page, episodes: list[str]): def check_title(page:Page, url: str): show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) From edcd2e5a081e436001a7e5d869e4ff277995dc2f Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Sat, 4 Feb 2023 18:22:53 -0500 Subject: [PATCH 08/36] :white_check_mark: take screenshot when test fails; add multiple browser test; Lint --- pytest.ini | 2 +- test/conftest.py | 51 +++++++++++++++++++++++++++---- test/e2e/test_404.py | 20 ++++--------- test/e2e/test_calendar.py | 15 ++-------- test/e2e/test_contact.py | 46 +++++++++++++--------------- test/e2e/test_episode.py | 45 ++++++++++++++-------------- test/e2e/test_home.py | 55 ++++++++++++++-------------------- test/e2e/test_infra_configs.py | 8 ++--- 8 files changed, 125 insertions(+), 117 deletions(-) diff --git a/pytest.ini b/pytest.ini index d8bb23af2..d786acee0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,7 +2,7 @@ python_files = test_* python_classes = *Tests python_functions = test_* -addopts = -ra -v +addopts = -ra -v --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" diff --git a/test/conftest.py b/test/conftest.py index 19dc5279a..2bba810ac 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,4 +1,3 @@ - from pathlib import Path from typing import Dict, Generator, List, Tuple, Callable from pytest import fixture @@ -11,6 +10,7 @@ def get_test_dir() -> Path: return Path(__file__).parent.absolute() + @fixture def screenshot_dir() -> Path: return Path('screenshots/') @@ -102,7 +102,21 @@ def expected_rss_feeds() -> List[Dict[str,str,]]: ] @fixture -def expected_dropdown_items() -> Dict[str,List[Dict[str,str]]]: +def expected_rss_feeds() -> list[dict[str, str, ]]: + return [ + {'href': 'http://feeds2.feedburner.com/JupiterBroadcasting', 'title': 'All Shows Feed - Audio'}, + {'href': 'http://feeds2.feedburner.com/AllJupiterVideos', 'title': 'All Shows Feed - Video'}, + {'href': 'https://coder.show/rss', 'title': 'Coder Radio'}, + {'href': 'https://extras.show/rss', 'title': 'Jupiter EXTRAS'}, + {'href': 'https://linuxactionnews.com/rss', 'title': 'Linux Action News'}, + {'href': 'https://linuxunplugged.com/rss', 'title': 'LINUX Unplugged'}, + {'href': 'https://www.officehours.hair/rss', 'title': 'Office Hours'}, + {'href': 'https://selfhosted.show/rss', 'title': 'Self-Hosted'} + ] + + +@fixture +def expected_dropdown_items() -> dict[str, list[dict[str, str]]]: return { "Shows": [ {'href': '/show/coder-radio/', 'title': 'Coder Radio'}, @@ -131,16 +145,18 @@ def expected_dropdown_items() -> Dict[str,List[Dict[str,str]]]: ] } + @fixture -def expected_dropdowns() -> List[Dict[str,str]]: +def expected_dropdowns() -> list[dict[str, str]]: return [ {'title': 'Shows', 'href': '/show/'}, {'title': 'People', 'href': "/people/"}, {'title': 'Community', 'href': "/community/"} ] + @fixture -def expect_nav_items() -> List[Dict[str,str]]: +def expect_nav_items() -> list[dict[str, str]]: return [ {'title': 'Sponsors', 'href': '/sponsors/'}, {'title': 'Live', 'href': '/live/'}, @@ -150,7 +166,8 @@ def expect_nav_items() -> List[Dict[str,str]]: {'title': 'Membership', 'href': '/membership/'}, # commenting out for now PR #399 # {'title': 'Archive', 'href': '/archive'}, - # failing on tests here: https://github.com/JupiterBroadcasting/jupiterbroadcasting.com/runs/8254156209?check_suite_focus=true#step:9:26 + # failing on tests here: + # https://github.com/JupiterBroadcasting/jupiterbroadcasting.com/runs/8254156209?check_suite_focus=true#step:9:26 {'title': 'Contact', 'href': '/contact/'}, ] @@ -179,4 +196,26 @@ def handle_route(route: Route) -> None: page.route( "https://jupiter.tube/api/v1/video-channels/live/**", handle_route - ) \ No newline at end of file + ) + +# https://playwright.dev/python/docs/api-testing#configure +# used for doing similar requests to API calls +@fixture(scope="session") +def api_request_context( + # base playwright context/object + playwright: Playwright, + # from the pytest-base-url plugin Playwright installs (automatically) + # so we're not having to hard-code the URL + base_url: base_url, + # Generator is returned based on Playwright docs +) -> Generator[APIRequestContext, None, None]: + # creates APIRequestContext to allow requests to be made + # using the base_url variable (from the plugin) to define + # the base_url which'll allow requests relative to that base_url + request_context = playwright.request.new_context(base_url=base_url) + # essentially a "return", but used with generators + yield request_context + # 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 + # https://github.com/microsoft/playwright.dev/blob/d9b4a2f3bd0510ea89c87ed230b8241eb33b6688/python/docs/api-testing.mdx#writing-api-test + request_context.dispose() diff --git a/test/e2e/test_404.py b/test/e2e/test_404.py index 9dc663cdd..9c77b1af6 100644 --- a/test/e2e/test_404.py +++ b/test/e2e/test_404.py @@ -1,27 +1,19 @@ -from pathlib import Path from pytest import fixture from playwright.sync_api import Page, expect -""" -Code here will run for every test in this file -""" @fixture(autouse=True) def setup(page: Page): + """ + Code here will run for every test in this file + """ page.goto("/404.html") # TODO: Change to random address once #483 is merged -""" -Take a screenshot of the 404 page for manual review -""" -def test_404_screenshot(page: Page, screenshot_dir: Path): - page.screenshot(path=f"{screenshot_dir}/404.png", full_page=True) - - -""" -Ensure text "Page Not Found" and unicorn image is present on the 404 page -""" 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") diff --git a/test/e2e/test_calendar.py b/test/e2e/test_calendar.py index 8d43f3bbf..462e60789 100644 --- a/test/e2e/test_calendar.py +++ b/test/e2e/test_calendar.py @@ -1,20 +1,11 @@ -from pathlib import Path from pytest import fixture from playwright.sync_api import Page, expect, FrameLocator, Locator -""" -Code here will run for every test in this file -""" @fixture(autouse=True) def setup(page: Page): + """ + Code here will run for every test in this file + """ page.goto("/calendar") page.wait_for_load_state("networkidle") - - -""" -Take a screenshot of the calendar page for manual review -""" -def test_calendar_screenshot(page: Page, screenshot_dir: Path): - page.screenshot(path=f"{screenshot_dir}/calendar.png", full_page=True) - diff --git a/test/e2e/test_contact.py b/test/e2e/test_contact.py index 51fc60f5f..473b7c561 100644 --- a/test/e2e/test_contact.py +++ b/test/e2e/test_contact.py @@ -1,35 +1,29 @@ -from typing import Dict from pathlib import Path from pytest import fixture from playwright.sync_api import Page, FrameLocator, Locator -""" -Code here will run for every test in this file -""" + @fixture(autouse=True) def setup(page: Page): + """ + Code here will run for every test in this file + """ page.goto("/contact") -""" -Take a screenshot of the contact page for manual review -""" -def test_contact_screenshot(page: Page, screenshot_dir: Path): - 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): + """ + 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") @@ -39,14 +33,14 @@ 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 - button_location: Dict = submit_button.bounding_box() + # 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" diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py index 6a0a90d44..eecf5fb30 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -1,11 +1,8 @@ import os import re -from functools import wraps -from typing import Any -from pathlib import Path from pytest import fixture, mark -from playwright.sync_api import Page, FrameLocator, Locator, expect +from playwright.sync_api import Page, FrameLocator, expect """ @@ -22,15 +19,16 @@ } ] -""" -Generate list of episode URLS for testing. This list consists of the following -* For each show: - * 5 oldest episodes - * 5 newest episodes -* A specified set of episodes that contain "out of the ordinary" things for an episode (an example being a high number of guests -""" + @fixture() def episodes() -> list[str]: + """ + Generate list of episode URLS for testing. This list consists of the following + * For each show: + * 5 oldest episodes + * 5 newest episodes + * A specified set of episodes that contain "out of the ordinary" things for an episode (an example being a high number of guests + """ test_episodes: list[str] = [] shows_dir: str = "content/show" meta_files: [str] = ["_index.md", "subscribe.md"] @@ -45,16 +43,17 @@ def episodes() -> list[str]: test_episodes += [f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" for e in all_episodes[-5::] if e not in test_episodes] # 5 most recent episodes of a show # Defined list of edge case episodes - test_episodes += [f"/show/{e['show']}/{e['number']}" for e in EXTRA_EPISODES if e not in test_episodes] # Extra episodes worth testing + test_episodes += [f"/show/{e['show']}/{e['number']}" for e in EXTRA_EPISODES if e not in test_episodes] return test_episodes -""" -Make sure the title (text in the tab) contains the show name, episode number, and network name (Jupiter Broadcasting) -""" @mark.dev def test_page_title(page: Page, episodes: list[str]): + """ + Make sure the title (text in the tab) contains the show name, episode number, and + network name (Jupiter Broadcasting) + """ # TODO figure out how to call this loop async for url in episodes: show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url @@ -62,17 +61,19 @@ def test_page_title(page: Page, episodes: list[str]): print(f"{show} - {episode_number}") expect(page).to_have_title(re.compile(f".* | {show} {episode_number} | Jupiter Broadcasting")) -""" -Test podverse player to and make sure the correct show is displayed -""" + def test_podverse_player(page: Page, episodes: list[str]): - def check_title(page:Page, url: str): - show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url + """ + Test podverse player to and make sure the correct show is displayed + """ + def check_title(page: Page, url: str): + show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) pv_player: FrameLocator = page.frame_locator("#pv-embed-player") # expect(pv_player).to_be_visible() - pv_player_show_text: Locator = pv_player.locator(".embed-player-header-top-text").text_content() - assert pv_player_show_text.replace('-', ' ').lower() == show.replace('-', ' ').lower(), f"Podverse player show for {url} says {pv_player_show_text}" + pv_player_show_text: str = pv_player.locator(".embed-player-header-top-text").text_content() + assert pv_player_show_text.replace('-', ' ').lower() == show.replace('-', ' ').lower(), \ + f"Podverse player show for {url} says {pv_player_show_text}" # TODO figure out how to call this loop async for url in episodes: diff --git a/test/e2e/test_home.py b/test/e2e/test_home.py index dbfb7c556..688c5e587 100644 --- a/test/e2e/test_home.py +++ b/test/e2e/test_home.py @@ -1,41 +1,34 @@ -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 -""" -Code here will run for every test in this file -""" @fixture(autouse=True) def setup(page: Page): + """ + Code here will run for every test in this file + """ page.goto("/") -""" -Take a screenshot of the home page for manual review -""" -def test_homepage_screenshot(page: Page, screenshot_dir: Path): - page.screenshot(path=f"{screenshot_dir}/home.png", full_page=True) - - -""" -Ensure the large logo and text underneath it is visible on the screen -""" 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' + ) -""" -Ensure the pagination loads different episodes -""" def test_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() @@ -45,20 +38,20 @@ def test_pagination(page: Page): assert first_card != first_card_second_page -""" -Ensure the RSS feeds menu contains all the fields specified in the conftest file -""" -def test_rss_feeds(page: Page, expected_rss_feeds: List[Dict[str,str]]): +def test_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']) -""" -Tests dropdowns to make sure the dropdown items have links, and all items specified in conftest are present -""" @mark.dev -def test_dropdowns(page: Page, expected_dropdown_items: Dict[str,List[Dict[str,str]]]): +def test_dropdowns(page: Page, expected_dropdown_items: Dict[str, List[Dict[str, str]]]): + """ + Tests dropdowns to make sure the dropdown items have links, and all items specified in conftest are present + """ for dropdown_text, child_elements in expected_dropdown_items.items(): # dropdown item to hover over in menu @@ -87,11 +80,10 @@ def test_dropdowns(page: Page, expected_dropdown_items: Dict[str,List[Dict[str,s expect(item).to_be_visible() -""" -Test main navigation items -""" 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') @@ -100,7 +92,6 @@ def test_nav(page: Page, expected_dropdowns, expect_nav_items): 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): diff --git a/test/e2e/test_infra_configs.py b/test/e2e/test_infra_configs.py index 645828bd6..2cef3aebe 100644 --- a/test/e2e/test_infra_configs.py +++ b/test/e2e/test_infra_configs.py @@ -1,13 +1,13 @@ from playwright.sync_api import APIRequestContext -""" -Check to make sure the files required for matrix federation and name resolution -are present and contain necessary details -""" 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": { From dc4bc6c828aecf52779936284c5b337ffeda08fd Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Sun, 5 Feb 2023 20:31:04 -0500 Subject: [PATCH 09/36] :white_check_mark: Add Mobile Live status test written by elreydetoda --- test/conftest.py | 124 ++++++++++++++++++++++++++++++++------- test/e2e/test_contact.py | 1 - test/e2e/test_home.py | 1 - test/e2e/test_live.py | 3 +- 4 files changed, 105 insertions(+), 24 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 2bba810ac..f8345cb06 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -101,6 +101,81 @@ def expected_rss_feeds() -> List[Dict[str,str,]]: { 'href': 'https://selfhosted.show/rss', 'title': 'Self-Hosted'} ] +@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+", + ] +) +def mobile_device_tuple( + # how to access the params + request: SubRequest, + # browser object given by playwright built-in fixture + browser: Browser, + # Playwright object given by playwright built-in fixture + playwright: Playwright, + # from the pytest-base-url plugin Playwright installs (automatically) + # so we're not having to hard-code the URL + base_url: base_url, +) -> tuple[Page, str]: + """ + identified all possible device types by running the following in a python interpreter + shell commands + + # python interpreter + with sync_playwright() as playwright: + Path('./devices.json').write_text(j_dumps(playwright.devices, indent=2)) + + # shell cmd + jq '. | keys' devices.json| less + + also should be able to view them here: + https://github.com/microsoft/playwright/blob/v1.25.2/packages/playwright-core/src/server/deviceDescriptorsSource.json + """ + + # 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] + ) + try: + # essentially a "return", but used with generators + yield context.new_page(), request.param + except Exception as e: + raise e + finally: + # supposed to get rid of cookies/other stored info after generator is done + # https://playwright.dev/python/docs/api/class-apirequestcontext#api-request-context-dispose + # https://github.com/microsoft/playwright.dev/blob/d9b4a2f3bd0510ea89c87ed230b8241eb33b6688/python/docs/api-testing.mdx#writing-api-test + context.close() + + +@fixture(scope="session") +def api_request_context( + playwright: Playwright, # base playwright context/object + # From the pytest-base-url plugin Playwright installs (automatically) so we're not having to hard-code the URL + base_url: base_url, +) -> Generator[APIRequestContext, None, None]: # Generator is returned based on Playwright docs + """ + https://playwright.dev/python/docs/api-testing#configure + used for doing similar requests to API calls + """ + # creates APIRequestContext to allow requests to be made + # using the base_url variable (from the plugin) to define + # the base_url which'll allow requests relative to that base_url + request_context = playwright.request.new_context(base_url=base_url) + # essentially a "return", but used with generators + yield request_context + # 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 + # https://github.com/microsoft/playwright.dev/blob/d9b4a2f3bd0510ea89c87ed230b8241eb33b6688/python/docs/api-testing.mdx#writing-api-test + request_context.dispose() + + @fixture def expected_rss_feeds() -> list[dict[str, str, ]]: return [ @@ -198,24 +273,31 @@ def handle_route(route: Route) -> None: handle_route ) -# https://playwright.dev/python/docs/api-testing#configure -# used for doing similar requests to API calls -@fixture(scope="session") -def api_request_context( - # base playwright context/object - playwright: Playwright, - # from the pytest-base-url plugin Playwright installs (automatically) - # so we're not having to hard-code the URL - base_url: base_url, - # Generator is returned based on Playwright docs -) -> Generator[APIRequestContext, None, None]: - # creates APIRequestContext to allow requests to be made - # using the base_url variable (from the plugin) to define - # the base_url which'll allow requests relative to that base_url - request_context = playwright.request.new_context(base_url=base_url) - # essentially a "return", but used with generators - yield request_context - # 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 - # https://github.com/microsoft/playwright.dev/blob/d9b4a2f3bd0510ea89c87ed230b8241eb33b6688/python/docs/api-testing.mdx#writing-api-test - request_context.dispose() +@fixture +def get_live_event(get_test_dir: Path) -> str: + return Path(get_test_dir / 'fixture_files/jb-live_sample-live-event.json').read_text() + + +@fixture +def set_live(get_live_event: str) -> tuple[Callable, str]: + return _replace_live_event, get_live_event +# return replace_live_event + + +@staticmethod +def _replace_live_event(page: Page, live_event: str) -> None: + def handle_route(route: Route) -> None: + # fetch original response + response = page.request.fetch(route.request) + + # setting live event + route.fulfill( + # Pass all fields from the response + response=response, + # override body + body=live_event.strip() + ) + page.route( + "https://jupiter.tube/api/v1/video-channels/live/videos?isLive=true&skipCount=false&count=1&sort=-createdAt", + handle_route + ) diff --git a/test/e2e/test_contact.py b/test/e2e/test_contact.py index 473b7c561..3088c5f75 100644 --- a/test/e2e/test_contact.py +++ b/test/e2e/test_contact.py @@ -3,7 +3,6 @@ from playwright.sync_api import Page, FrameLocator, Locator - @fixture(autouse=True) def setup(page: Page): """ diff --git a/test/e2e/test_home.py b/test/e2e/test_home.py index 688c5e587..19a78d02c 100644 --- a/test/e2e/test_home.py +++ b/test/e2e/test_home.py @@ -47,7 +47,6 @@ def test_rss_feeds(page: Page, expected_rss_feeds: List[Dict[str, str]]): expect(element).to_contain_text(rss_feed['title']) -@mark.dev def test_dropdowns(page: Page, expected_dropdown_items: Dict[str, List[Dict[str, str]]]): """ Tests dropdowns to make sure the dropdown items have links, and all items specified in conftest are present diff --git a/test/e2e/test_live.py b/test/e2e/test_live.py index d16c114fd..217630ddc 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -56,7 +56,8 @@ def test_mobile_live_indicator( # 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 + # 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" From 6f8e5ea66c6d34df65c8317b69239ebf6902fe10 Mon Sep 17 00:00:00 2001 From: Kyle Potts Date: Mon, 13 Feb 2023 18:32:39 -0500 Subject: [PATCH 10/36] fixup firefox bug --- test/conftest.py | 34 ++++++++- test/e2e/test_live.py | 14 ++-- themes/jb/layouts/episode/Untitled-1.sh | 93 +++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 themes/jb/layouts/episode/Untitled-1.sh diff --git a/test/conftest.py b/test/conftest.py index f8345cb06..bc21501f5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -135,12 +135,20 @@ def mobile_device_tuple( also should be able to view them here: https://github.com/microsoft/playwright/blob/v1.25.2/packages/playwright-core/src/server/deviceDescriptorsSource.json """ + params=playwright.devices[request.param] + # based on here: https://playwright.dev/python/docs/emulation#devices + params = playwright.devices[request.param] + if browser.browser_type.name == "firefox": + # bug in playwright for firefox https://github.com/microsoft/playwright/issues/16622 + # https://github.com/Lookyloo/PlaywrightCapture/blob/34ab2c3e096fc3bc1ebef217063b78f3554d8a42/playwrightcapture/capture.py#L188 + del params['is_mobile'] + context = browser.new_context( base_url=base_url, # based on this info: https://playwright.dev/python/docs/emulation#devices - **playwright.devices[request.param] + **params ) try: # essentially a "return", but used with generators @@ -274,9 +282,31 @@ def handle_route(route: Route) -> None: ) @fixture -def get_live_event(get_test_dir: Path) -> str: +def live_event_json(get_test_dir: Path) -> str: return Path(get_test_dir / 'fixture_files/jb-live_sample-live-event.json').read_text() +@fixture +def replace_live_event(page: Page) -> Callable: + def handle_route(route: Route, p: Page, live_event: str) -> Callable[[Page, str], None]: + # fetch original response + response = p.request.fetch(route.request) + + # setting live event + route.fulfill( + # Pass all fields from the response + response=response, + # override body + body=live_event.strip() + ) + + def mock_jb_tube_request(p: Page, live_event: str): + p.route( + "https://jupiter.tube/api/v1/video-channels/live/videos?isLive=true&skipCount=false&count=1&sort=-createdAt", + lambda route: handle_route(route, p, live_event) + ) + + return mock_jb_tube_request + @fixture def set_live(get_live_event: str) -> tuple[Callable, str]: diff --git a/test/e2e/test_live.py b/test/e2e/test_live.py index 217630ddc..e6594e82c 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -10,8 +10,8 @@ def test_live_indicator( # intercepting reponses for live event, and make live set_live(page) - # go to the live page - page.goto("/live") +# # go to the live page +# page.goto("/live") # validate live button is red expect(page.locator("#mainnavigation.is-live").locator("#livebutton"),).to_have_css( @@ -19,12 +19,12 @@ def test_live_indicator( 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() +# # 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.evaluate("window.scrollTo(0, 0)") page.screenshot(path=f"{screenshot_dir}/live.png", full_page=True) diff --git a/themes/jb/layouts/episode/Untitled-1.sh b/themes/jb/layouts/episode/Untitled-1.sh new file mode 100644 index 000000000..1f9552104 --- /dev/null +++ b/themes/jb/layouts/episode/Untitled-1.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# +# +# +# +# +# + +exec > >(tee -i /var/log/stackscript.log) + +mkdir -p ~/.config +echo " +[DEFAULT] +default-user = user + +[user] +token = $CLI_TOKEN +region = us-east +type = g6-nanode-1 +image = linode/almalinux8 +" > ~/.config/linode-cli + +# Install linode-cli and set A record for registry domain +echo "installing python3-pip" +apt update -y +apt install -y python3-pip +pip3 install linode-cli + +linode-cli account view +echo "looking for $HOST_ROOT" +linode-cli domains list --text --format 'id,domain' --no-headers | grep $HOST_ROOT | awk '{ print $1 }' +domain_id=$(linode-cli domains list --text --format 'id,domain' --no-headers | grep $HOST_ROOT | awk '{ print $1 }') +echo "domain_id $domain_id" +my_ip=$(hostname -I | awk '{print $1}') +echo "my_ip $my_ip" + +echo "domain_id $domain_id" +echo "creating A record" +linode-cli domains records-create $domain_id --type A --name $REGISTRY_HOST --target $my_ip + +echo "sleeping to wait for A record to populate" +sleep 1m + +# Generate LetsEncrypt certs +echo "generating certs" +apt install certbot -y +certbot certonly --keep-until-expiring --standalone -d $REGISTRY_HOST --non-interactive --agree-tos -v --email $REGISTRY_EMAIL + +echo "creating combined certs" +cd /etc/letsencrypt/live/$REGISTRY_HOST/ +cp privkey.pem domain.key +cat cert.pem chain.pem > domain.crt +chmod 777 domain.crt +chmod 777 domain.key + + +Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh + + +#Setup cron job to renew certs +echo "setting up cron job" +line="30 2 * * 1 certbot renew >> /var/log/letsencrypt-renew.log" +(crontab -u root -l; echo "$line" ) | crontab -u root - + +# Generate encrypted password file for auth +echo "generating password file" +cd +mkdir auth +docker run --rm --mount type=bind,source="$(pwd)"/auth,target=/auth --entrypoint htpasswd httpd:2 -Bbn $REGISTRY_USERNAME $REGISTRY_PASSWORD > auth/htpasswd + + +echo "running registry image" +# Run registry docker image +docker run -d -p 443:5000 --restart=always --name registry \ + -v /etc/letsencrypt/live/$REGISTRY_HOST:/certs \ + -v /opt/docker-registry:/var/lib/registry \ + -v `pwd`/auth:/auth \ + -e "REGISTRY_AUTH=htpasswd" \ + -e "REGISTRY_AUTH_HTPASSWD_REALM=Axway Docker Registry" \ + -e REGISTRY_STORAGE=s3 \ + -e REGISTRY_STORAGE_S3_REGION=$S3_REGION \ + -e REGISTRY_STORAGE_S3_BUCKET="kp-registry" \ + -e REGISTRY_STORAGE_S3_REGIONENDPOINT=$S3_REGIONENDPOINT \ + -e REGISTRY_STORAGE_S3_SECURE=true \ + -e REGISTRY_STORAGE_S3_ACCESSKEY=$S3_ACCESSKEY \ + -e REGISTRY_STORAGE_S3_SECRETKEY=$S3_SECRETKEY \ + -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ + -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \ + -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \ + registry:2 \ No newline at end of file From 38e9abb64ff7dd25862976257300e6d9011ca441 Mon Sep 17 00:00:00 2001 From: Kyle Potts Date: Mon, 13 Feb 2023 18:36:03 -0500 Subject: [PATCH 11/36] remove: --- themes/jb/layouts/episode/Untitled-1.sh | 93 ------------------------- 1 file changed, 93 deletions(-) delete mode 100644 themes/jb/layouts/episode/Untitled-1.sh diff --git a/themes/jb/layouts/episode/Untitled-1.sh b/themes/jb/layouts/episode/Untitled-1.sh deleted file mode 100644 index 1f9552104..000000000 --- a/themes/jb/layouts/episode/Untitled-1.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash - -# -# -# -# -# -# - -exec > >(tee -i /var/log/stackscript.log) - -mkdir -p ~/.config -echo " -[DEFAULT] -default-user = user - -[user] -token = $CLI_TOKEN -region = us-east -type = g6-nanode-1 -image = linode/almalinux8 -" > ~/.config/linode-cli - -# Install linode-cli and set A record for registry domain -echo "installing python3-pip" -apt update -y -apt install -y python3-pip -pip3 install linode-cli - -linode-cli account view -echo "looking for $HOST_ROOT" -linode-cli domains list --text --format 'id,domain' --no-headers | grep $HOST_ROOT | awk '{ print $1 }' -domain_id=$(linode-cli domains list --text --format 'id,domain' --no-headers | grep $HOST_ROOT | awk '{ print $1 }') -echo "domain_id $domain_id" -my_ip=$(hostname -I | awk '{print $1}') -echo "my_ip $my_ip" - -echo "domain_id $domain_id" -echo "creating A record" -linode-cli domains records-create $domain_id --type A --name $REGISTRY_HOST --target $my_ip - -echo "sleeping to wait for A record to populate" -sleep 1m - -# Generate LetsEncrypt certs -echo "generating certs" -apt install certbot -y -certbot certonly --keep-until-expiring --standalone -d $REGISTRY_HOST --non-interactive --agree-tos -v --email $REGISTRY_EMAIL - -echo "creating combined certs" -cd /etc/letsencrypt/live/$REGISTRY_HOST/ -cp privkey.pem domain.key -cat cert.pem chain.pem > domain.crt -chmod 777 domain.crt -chmod 777 domain.key - - -Install Docker -curl -fsSL https://get.docker.com -o get-docker.sh -sh get-docker.sh - - -#Setup cron job to renew certs -echo "setting up cron job" -line="30 2 * * 1 certbot renew >> /var/log/letsencrypt-renew.log" -(crontab -u root -l; echo "$line" ) | crontab -u root - - -# Generate encrypted password file for auth -echo "generating password file" -cd -mkdir auth -docker run --rm --mount type=bind,source="$(pwd)"/auth,target=/auth --entrypoint htpasswd httpd:2 -Bbn $REGISTRY_USERNAME $REGISTRY_PASSWORD > auth/htpasswd - - -echo "running registry image" -# Run registry docker image -docker run -d -p 443:5000 --restart=always --name registry \ - -v /etc/letsencrypt/live/$REGISTRY_HOST:/certs \ - -v /opt/docker-registry:/var/lib/registry \ - -v `pwd`/auth:/auth \ - -e "REGISTRY_AUTH=htpasswd" \ - -e "REGISTRY_AUTH_HTPASSWD_REALM=Axway Docker Registry" \ - -e REGISTRY_STORAGE=s3 \ - -e REGISTRY_STORAGE_S3_REGION=$S3_REGION \ - -e REGISTRY_STORAGE_S3_BUCKET="kp-registry" \ - -e REGISTRY_STORAGE_S3_REGIONENDPOINT=$S3_REGIONENDPOINT \ - -e REGISTRY_STORAGE_S3_SECURE=true \ - -e REGISTRY_STORAGE_S3_ACCESSKEY=$S3_ACCESSKEY \ - -e REGISTRY_STORAGE_S3_SECRETKEY=$S3_SECRETKEY \ - -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ - -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \ - -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \ - registry:2 \ No newline at end of file From 4d4f87d5265ca534707e5c2a086c9ec597d70fd2 Mon Sep 17 00:00:00 2001 From: Kyle Potts Date: Mon, 13 Feb 2023 18:38:14 -0500 Subject: [PATCH 12/36] uncomment test --- test/e2e/test_live.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/e2e/test_live.py b/test/e2e/test_live.py index e6594e82c..217630ddc 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -10,8 +10,8 @@ def test_live_indicator( # intercepting reponses for live event, and make live set_live(page) -# # go to the live page -# page.goto("/live") + # go to the live page + page.goto("/live") # validate live button is red expect(page.locator("#mainnavigation.is-live").locator("#livebutton"),).to_have_css( @@ -19,12 +19,12 @@ def test_live_indicator( 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() + # 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.evaluate("window.scrollTo(0, 0)") page.screenshot(path=f"{screenshot_dir}/live.png", full_page=True) From 30de5c19303c51bdb6788d2d61e95ece36a8b35c Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Tue, 28 Feb 2023 20:26:18 -0500 Subject: [PATCH 13/36] :wastebasket: rebase on recent updates --- test/conftest.py | 58 ++++++++++++++++++++----------------- test/e2e/test_edge-cases.py | 9 ------ test/e2e/test_episode.py | 9 ++++++ test/e2e/test_live.py | 10 +------ 4 files changed, 41 insertions(+), 45 deletions(-) delete mode 100644 test/e2e/test_edge-cases.py diff --git a/test/conftest.py b/test/conftest.py index bc21501f5..462f75f7a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,11 +1,12 @@ from pathlib import Path -from typing import Dict, Generator, List, Tuple, Callable +from typing import Generator, Callable from pytest import fixture 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 + @fixture def get_test_dir() -> Path: return Path(__file__).parent.absolute() @@ -47,7 +48,20 @@ def mobile_device_tuple( # from the pytest-base-url plugin Playwright installs (automatically) # so we're not having to hard-code the URL base_url: base_url, -) -> Tuple[Page,str]: +) -> tuple[Page,str]: + """ + identified all possible device types by running the following in a python interpreter + shell commands + + # python interpreter + with sync_playwright() as playwright: + Path('./devices.json').write_text(j_dumps(playwright.devices, indent=2)) + + # shell cmd + jq '. | keys' devices.json| less + + also should be able to view them here: + https://github.com/microsoft/playwright/blob/v1.25.2/packages/playwright-core/src/server/deviceDescriptorsSource.json + """ # based on here: https://playwright.dev/python/docs/emulation#devices context = browser.new_context( @@ -57,26 +71,26 @@ def mobile_device_tuple( ) try: # essentially a "return", but used with generators - yield (context.new_page(), request.param) - except Exception as excpt: - raise excpt + yield context.new_page(), request.param + except Exception as e: + raise e finally: - # supposed to get rid of coookies/other stored info after generator is done + # supposed to get rid of cookies/other stored info after generator is done # https://playwright.dev/python/docs/api/class-apirequestcontext#api-request-context-dispose # https://github.com/microsoft/playwright.dev/blob/d9b4a2f3bd0510ea89c87ed230b8241eb33b6688/python/docs/api-testing.mdx#writing-api-test context.close() -# https://playwright.dev/python/docs/api-testing#configure -# used for doing similar requests to API calls + @fixture(scope="session") def api_request_context( - # base playwright context/object - playwright: Playwright, - # from the pytest-base-url plugin Playwright installs (automatically) - # so we're not having to hard-code the URL - base_url: base_url, - # Generator is returned based on Playwright docs -) -> Generator[APIRequestContext, None, None]: + playwright: Playwright, # base playwright context/object + # From the pytest-base-url plugin Playwright installs (automatically) so we're not having to hard-code the URL + base_url: base_url, +) -> Generator[APIRequestContext, None, None]: # Generator is returned based on Playwright docs + """ + https://playwright.dev/python/docs/api-testing#configure + used for doing similar requests to API calls + """ # creates APIRequestContext to allow requests to be made # using the base_url variable (from the plugin) to define # the base_url which'll allow requests relative to that base_url @@ -88,18 +102,6 @@ def api_request_context( # https://github.com/microsoft/playwright.dev/blob/d9b4a2f3bd0510ea89c87ed230b8241eb33b6688/python/docs/api-testing.mdx#writing-api-test request_context.dispose() -@fixture -def expected_rss_feeds() -> List[Dict[str,str,]]: - return [ - { 'href': 'http://feeds2.feedburner.com/JupiterBroadcasting', 'title': 'All Shows Feed - Audio'}, - { 'href': 'http://feeds2.feedburner.com/AllJupiterVideos', 'title': 'All Shows Feed - Video'}, - { 'href': 'https://coder.show/rss', 'title': 'Coder Radio'}, - { 'href': 'https://extras.show/rss', 'title': 'Jupiter EXTRAS'}, - { 'href': 'https://linuxactionnews.com/rss', 'title': 'Linux Action News'}, - { 'href': 'https://linuxunplugged.com/rss', 'title': 'LINUX Unplugged'}, - { 'href': 'https://www.officehours.hair/rss', 'title': 'Office Hours'}, - { 'href': 'https://selfhosted.show/rss', 'title': 'Self-Hosted'} - ] @fixture( # doing session so it doesn't have to be re-created for each usage @@ -254,6 +256,7 @@ def expect_nav_items() -> list[dict[str, str]]: {'title': 'Contact', 'href': '/contact/'}, ] + @fixture def set_live() -> Callable: return _replace_live_event @@ -281,6 +284,7 @@ def handle_route(route: Route) -> None: handle_route ) + @fixture def live_event_json(get_test_dir: Path) -> str: return Path(get_test_dir / 'fixture_files/jb-live_sample-live-event.json').read_text() 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 index eecf5fb30..927c88c2d 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -78,3 +78,12 @@ def check_title(page: Page, url: str): # TODO figure out how to call this loop async for url in episodes: check_title(page, url) + + +def test_tag_broken_link_spaces(page: Page): + """ + Checking for broken tag links with spaces in them + """ + 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_live.py b/test/e2e/test_live.py index 217630ddc..c61dc0a3e 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -14,7 +14,7 @@ def test_live_indicator( page.goto("/live") # validate live button is red - expect(page.locator("#mainnavigation.is-live").locator("#livebutton"),).to_have_css( + expect(page.locator("#mainnavigation.is-live").locator("#livebutton")).to_have_css( name="background-color", value="rgb(255, 0, 0)", # red ) @@ -26,8 +26,6 @@ def test_live_indicator( 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], @@ -36,10 +34,6 @@ def test_mobile_live_indicator( ): # 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) @@ -72,8 +66,6 @@ def test_mobile_live_indicator( 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() From 15ea7ad0524aea2b08af7f23308c23633b4a3f64 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Wed, 1 Mar 2023 20:32:36 -0500 Subject: [PATCH 14/36] :recycle: Refactor tests --- test/e2e/test_contact.py | 2 +- test/e2e/test_home.py | 72 ++-------------------------------- test/e2e/test_navigation.py | 75 ++++++++++++++++++++++++++++++++++++ test/external/test_matrix.py | 12 +++--- 4 files changed, 86 insertions(+), 75 deletions(-) create mode 100644 test/e2e/test_navigation.py diff --git a/test/e2e/test_contact.py b/test/e2e/test_contact.py index 3088c5f75..676269ff3 100644 --- a/test/e2e/test_contact.py +++ b/test/e2e/test_contact.py @@ -11,7 +11,7 @@ def setup(page: Page): page.goto("/contact") -def test_submit_button_visible(page: Page, screenshot_dir: Path): +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. diff --git a/test/e2e/test_home.py b/test/e2e/test_home.py index 19a78d02c..e624c8898 100644 --- a/test/e2e/test_home.py +++ b/test/e2e/test_home.py @@ -1,7 +1,5 @@ -from typing import Dict, List -from urllib.parse import urlparse -from pytest import fixture, mark -from playwright.sync_api import Page, expect, Locator +from pytest import fixture +from playwright.sync_api import Page, expect @fixture(autouse=True) @@ -25,7 +23,7 @@ def test_homepage_has_logo(page: Page): ) -def test_pagination(page: Page): +def test_home_pagination(page: Page): """ Ensure the pagination loads different episodes """ @@ -36,67 +34,3 @@ def test_pagination(page: Page): assert "/page/2" in page.url 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]]): - """ - 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_dropdowns(page: Page, expected_dropdown_items: Dict[str, List[Dict[str, str]]]): - """ - Tests dropdowns to make sure the dropdown items have links, and all items specified in conftest are present - """ - for dropdown_text, child_elements in expected_dropdown_items.items(): - - # dropdown item to hover over in menu - parent_element: Locator = page.locator(f'.navbar-start >> a:has-text("{dropdown_text}"):visible') - - # hover to show elements - parent_element.hover() - - # this is because Shows's url is show - singular = dropdown_text.lower().rstrip('s') - # test to make sure menu items hyperlink - expect(parent_element).to_have_attribute('href', f'/{singular}/') - - # finds sibling element, which contains all the dropdown elements - dropdown_elements: Locator = page.locator(f'a:has-text("{dropdown_text}") + .navbar-dropdown') - - for dropdown_item in child_elements: - item: Locator = dropdown_elements.locator(f'a.navbar-item:has-text("{dropdown_item["title"]}")') - try: - # check if the path matches exactly what's in our expected output - expect(item).to_have_attribute('href', dropdown_item['href']) - except AssertionError: - # if not, then it's a relative links (i.e. /community/irc/) - # so just comparing the path of the item - assert urlparse(item.get_attribute('href')).path == dropdown_item['href'] - expect(item).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_navigation.py b/test/e2e/test_navigation.py new file mode 100644 index 000000000..6f17fb736 --- /dev/null +++ b/test/e2e/test_navigation.py @@ -0,0 +1,75 @@ +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]]]): + """ + Tests dropdowns to make sure the dropdown items have links, and all items specified in conftest are present + """ + for dropdown_text, child_elements in expected_dropdown_items.items(): + + # dropdown item to hover over in menu + parent_element: Locator = page.locator(f'.navbar-start >> a:has-text("{dropdown_text}"):visible') + + # hover to show elements + parent_element.hover() + + # this is because Shows's url is show + singular = dropdown_text.lower().rstrip('s') + # test to make sure menu items hyperlink + expect(parent_element).to_have_attribute('href', f'/{singular}/') + + # finds sibling element, which contains all the dropdown elements + dropdown_elements: Locator = page.locator(f'a:has-text("{dropdown_text}") + .navbar-dropdown') + + for dropdown_item in child_elements: + item: Locator = dropdown_elements.locator(f'a.navbar-item:has-text("{dropdown_item["title"]}")') + try: + # check if the path matches exactly what's in our expected output + expect(item).to_have_attribute('href', dropdown_item['href']) + except AssertionError: + # if not, then it's a relative links (i.e. /community/irc/) + # so just comparing the path of the item + assert urlparse(item.get_attribute('href')).path == dropdown_item['href'] + expect(item).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/external/test_matrix.py b/test/external/test_matrix.py index 56195b1c0..bfe01b180 100644 --- a/test/external/test_matrix.py +++ b/test/external/test_matrix.py @@ -2,12 +2,14 @@ from playwright.sync_api import APIRequestContext -""" -Call the matrix federation API to make sure the JB federation is working. -""" @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" From 7ce7e46d60c76dc68f96ba403ca575025e554e73 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Wed, 1 Mar 2023 20:33:26 -0500 Subject: [PATCH 15/36] :test_tube: update tag test to look at episode list (failing) --- test/e2e/test_episode.py | 43 +++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py index 927c88c2d..9d728531a 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -16,6 +16,10 @@ { # No Sponsor "show": "coder-radio", "number": "343" + }, + { # Emoji tags + "show": "linux-unplugged", + "number": "489" } ] @@ -36,33 +40,46 @@ def episodes() -> list[str]: # Fist and last 5 episodes for each show for show in os.listdir(shows_dir): show_url: str = f"/show/{show}" - all_episodes: list[str] = [e for e in os.listdir(f"{shows_dir}/{show}") if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files] + all_episodes: list[str] = [ + e for e in os.listdir(f"{shows_dir}/{show}") + if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files + ] all_episodes.sort() - test_episodes += [f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" for e in all_episodes[:5:] if e not in test_episodes] # First 5 episodes of a show - test_episodes += [f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" for e in all_episodes[-5::] if e not in test_episodes] # 5 most recent episodes of a show + # First 5 episodes of a show + test_episodes += [ + f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" + for e in all_episodes[:5:] if e not in test_episodes + ] + # 5 most recent episodes of a show + test_episodes += [ + f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" + for e in all_episodes[-5::] if e not in test_episodes + ] # Defined list of edge case episodes - test_episodes += [f"/show/{e['show']}/{e['number']}" for e in EXTRA_EPISODES if e not in test_episodes] + test_episodes += [ + f"/show/{e['show']}/{e['number']}" + for e in EXTRA_EPISODES if e not in test_episodes + ] return test_episodes -@mark.dev -def test_page_title(page: Page, episodes: list[str]): +def test_episode_page_title(page: Page, episodes: list[str]): """ Make sure the title (text in the tab) contains the show name, episode number, and network name (Jupiter Broadcasting) """ # TODO figure out how to call this loop async for url in episodes: - show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url + show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) print(f"{show} - {episode_number}") expect(page).to_have_title(re.compile(f".* | {show} {episode_number} | Jupiter Broadcasting")) -def test_podverse_player(page: Page, episodes: list[str]): +def test_episodes_podverse_player(page: Page, episodes: list[str]): """ Test podverse player to and make sure the correct show is displayed """ @@ -80,10 +97,12 @@ def check_title(page: Page, url: str): check_title(page, url) -def test_tag_broken_link_spaces(page: Page): +def test_episode_tag_broken_link_spaces(page: Page, episodes: list[str]): """ Checking for broken tag links with spaces in them """ - 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() + for url in episodes: + show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url + page.goto(url) + page.locator(".tag > a", has_text=show).click() + expect(page.locator(".title", has_text=f"Tag: {show}")).to_be_visible() From 4137fd18238e628f98ff6c84d6a9842365251cb8 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Sat, 25 Mar 2023 23:47:18 -0400 Subject: [PATCH 16/36] :test_tube: work on tag testing --- test/e2e/test_episode.py | 29 +++++++++++++++++++++++------ test/e2e/test_live.py | 6 +++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py index 9d728531a..156a2f07d 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -31,7 +31,8 @@ def episodes() -> list[str]: * For each show: * 5 oldest episodes * 5 newest episodes - * A specified set of episodes that contain "out of the ordinary" things for an episode (an example being a high number of guests + * A specified set of episodes that contain "out of the ordinary" things for an episode (an example being a high + number of guests) """ test_episodes: list[str] = [] shows_dir: str = "content/show" @@ -75,7 +76,7 @@ def test_episode_page_title(page: Page, episodes: list[str]): for url in episodes: show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) - print(f"{show} - {episode_number}") + # print(f"{show} - {episode_number}") expect(page).to_have_title(re.compile(f".* | {show} {episode_number} | Jupiter Broadcasting")) @@ -97,12 +98,28 @@ def check_title(page: Page, url: str): check_title(page, url) +@mark.dev def test_episode_tag_broken_link_spaces(page: Page, episodes: list[str]): """ - Checking for broken tag links with spaces in them + Checks for tags with spaces in the name. If there is a space in that tag name, it render the tags page to make sure + it exists. """ + checked_tags: list[str] = [] # List of tag pages that were already tested for url in episodes: - show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) - page.locator(".tag > a", has_text=show).click() - expect(page.locator(".title", has_text=f"Tag: {show}")).to_be_visible() + tags: list[str] = [t for t in page.locator(".tag > a").all_text_contents() if ' ' in t] + for tag in tags: + if tag in checked_tags: + continue + print(f"{tag} at http://localhost:1313{url}") + page.locator(".tag > a", has_text=tag).click() + expect(page.locator("h1.title", has_text=f"Tag: {tag}")).to_be_visible() + checked_tags.append(tag) + page.goto(url) # Go back to episode page to click on the next tag + + +def test_episode_emoji_tags(page: Page, episodes: list[str]): + """ + Check to see if emoji tags are on the episode and renders tag page of that emoji to make sure it exitsts + """ + pass diff --git a/test/e2e/test_live.py b/test/e2e/test_live.py index c61dc0a3e..9eace9f43 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -79,6 +79,6 @@ def test_mobile_live_indicator( value="rgb(255, 0, 0)", # red ) - mobile_device.locator("#mainnavigation").locator( - ".navbar-menu.is-active" - ).screenshot(path=f"{screenshot_dir}/live-mobile_navbar.png") + # mobile_device.locator("#mainnavigation").locator( + # ".navbar-menu.is-active" + # ).screenshot(path=f"{screenshot_dir}/live-mobile_navbar.png") From 812230d1a57d0760d3cc643f0fbbd0cd22619359 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Sun, 26 Mar 2023 22:39:11 -0400 Subject: [PATCH 17/36] Skip tests with open issues --- test/e2e/test_episode.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/e2e/test_episode.py b/test/e2e/test_episode.py index 156a2f07d..d87e89429 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -88,7 +88,7 @@ def check_title(page: Page, url: str): show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url page.goto(url) pv_player: FrameLocator = page.frame_locator("#pv-embed-player") - # expect(pv_player).to_be_visible() + # expect(pv_player).to_be_visible() # TODO: Need to look into why this fails pv_player_show_text: str = pv_player.locator(".embed-player-header-top-text").text_content() assert pv_player_show_text.replace('-', ' ').lower() == show.replace('-', ' ').lower(), \ f"Podverse player show for {url} says {pv_player_show_text}" @@ -98,7 +98,7 @@ def check_title(page: Page, url: str): check_title(page, url) -@mark.dev +@mark.skip("Issue #534 as well as issue with gnome software vs gnome-software") def test_episode_tag_broken_link_spaces(page: Page, episodes: list[str]): """ Checks for tags with spaces in the name. If there is a space in that tag name, it render the tags page to make sure @@ -111,13 +111,14 @@ def test_episode_tag_broken_link_spaces(page: Page, episodes: list[str]): for tag in tags: if tag in checked_tags: continue - print(f"{tag} at http://localhost:1313{url}") + # print(f"{tag} at http://localhost:1313{url}") page.locator(".tag > a", has_text=tag).click() expect(page.locator("h1.title", has_text=f"Tag: {tag}")).to_be_visible() checked_tags.append(tag) page.goto(url) # Go back to episode page to click on the next tag +@mark.skip("Issue #516") def test_episode_emoji_tags(page: Page, episodes: list[str]): """ Check to see if emoji tags are on the episode and renders tag page of that emoji to make sure it exitsts From ec655b9efab8f31e3eab6daed124efee3c00b86d Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Tue, 3 Nov 2026 22:19:00 -0500 Subject: [PATCH 18/36] :wrench: Run e2e tests in parallel; gate per-episode tests to PRs touching episode content - pytest.ini: -n auto, --color=yes, register 'episodes' marker - Makefile: dev-tests target (hugo serve --disableFastRender) so pages are pre-rendered and parallel workers don't time out on first render - e2e.yml: PRs run -m 'not episodes' unless they change episode content/templates --- .github/workflows/e2e.yml | 21 ++++++++++++++++++++- Makefile | 3 +++ pytest.ini | 3 ++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 87833fb5f..5c3b954b5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,6 +16,25 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + with: + # full history so the PR base commit is available to diff against + fetch-depth: 0 + + - name: Select tests + id: select + run: | + markers="not periodic" + if [ "${{ github.event_name }}" = "pull_request" ] && ! git diff --name-only \ + ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- \ + content/show content/people content/sponsors \ + themes/jb/layouts/episode themes/jb/layouts/partials/episode \ + themes/jb/layouts/partials/people themes/jb/layouts/partials/sponsor \ + themes/jb/layouts/partials/meta.html \ + test/e2e/test_episode.py test/e2e/episode_data.py test/conftest.py pytest.ini \ + | grep -q .; then + markers="not periodic and not episodes" + fi + echo "markers=$markers" >> "$GITHUB_OUTPUT" # using docker compose, so we can re-use local Dockerfiles - name: starting up containers @@ -30,7 +49,7 @@ 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 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/pytest.ini b/pytest.ini index d786acee0..fe0ee50ed 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,8 +2,9 @@ python_files = test_* python_classes = *Tests python_functions = test_* -addopts = -ra -v --browser chromium --browser firefox --browser webkit --screenshot only-on-failure --output screenshots +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" From cff3c49da43faf186472059aaff6bf0cede66159 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin Date: Tue, 3 Nov 2026 22:19:00 -0500 Subject: [PATCH 19/36] :test_tube: Parametrize episode tests from content front matter; add episode page tests - Sample: 5 oldest + 5 newest per show (by date) plus edge-case episodes - Fix tab-title regex that matched any title (unescaped |) - Player, support/subscribe, header image, hosts/guests, sponsors, tags, body links --- test/e2e/episode_data.py | 73 +++++++++++ test/e2e/test_episode.py | 262 +++++++++++++++++++++------------------ 2 files changed, 216 insertions(+), 119 deletions(-) create mode 100644 test/e2e/episode_data.py diff --git a/test/e2e/episode_data.py b/test/e2e/episode_data.py new file mode 100644 index 000000000..2c43f2120 --- /dev/null +++ b/test/e2e/episode_data.py @@ -0,0 +1,73 @@ +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"} + +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 sample_episodes() -> list[Episode]: + """ + 5 oldest + 5 newest episodes of every show, 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) + selected += by_date[:5] + by_date[-5:] + 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_episode.py b/test/e2e/test_episode.py index d87e89429..f9f62a1f4 100644 --- a/test/e2e/test_episode.py +++ b/test/e2e/test_episode.py @@ -1,126 +1,150 @@ -import os import re -from pytest import fixture, mark -from playwright.sync_api import Page, FrameLocator, expect - - -""" -Dictionary of episodes that contain known edge cases -""" -EXTRA_EPISODES: list[dict[str, str]] = [ - { # 3 Hosts and 3 Guests - "show": "linux-unplugged", - "number": "434" - }, - { # No Sponsor - "show": "coder-radio", - "number": "343" - }, - { # Emoji tags - "show": "linux-unplugged", - "number": "489" - } -] - - -@fixture() -def episodes() -> list[str]: - """ - Generate list of episode URLS for testing. This list consists of the following - * For each show: - * 5 oldest episodes - * 5 newest episodes - * A specified set of episodes that contain "out of the ordinary" things for an episode (an example being a high - number of guests) - """ - test_episodes: list[str] = [] - shows_dir: str = "content/show" - meta_files: [str] = ["_index.md", "subscribe.md"] - - # Fist and last 5 episodes for each show - for show in os.listdir(shows_dir): - show_url: str = f"/show/{show}" - all_episodes: list[str] = [ - e for e in os.listdir(f"{shows_dir}/{show}") - if os.path.isfile(f"{shows_dir}/{show}/{e}") and e not in meta_files - ] - all_episodes.sort() - - # First 5 episodes of a show - test_episodes += [ - f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" - for e in all_episodes[:5:] if e not in test_episodes - ] - # 5 most recent episodes of a show - test_episodes += [ - f"{show_url}/{os.path.splitext(e)[0].lstrip('0') or '0'}" - for e in all_episodes[-5::] if e not in test_episodes - ] - - # Defined list of edge case episodes - test_episodes += [ - f"/show/{e['show']}/{e['number']}" - for e in EXTRA_EPISODES if e not in test_episodes - ] - - return test_episodes - - -def test_episode_page_title(page: Page, episodes: list[str]): - """ - Make sure the title (text in the tab) contains the show name, episode number, and - network name (Jupiter Broadcasting) - """ - # TODO figure out how to call this loop async - for url in episodes: - show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url - page.goto(url) - # print(f"{show} - {episode_number}") - expect(page).to_have_title(re.compile(f".* | {show} {episode_number} | Jupiter Broadcasting")) - - -def test_episodes_podverse_player(page: Page, episodes: list[str]): - """ - Test podverse player to and make sure the correct show is displayed - """ - def check_title(page: Page, url: str): - show, episode_number = url.split('/')[2:4] # This skips the empty string and 'show' in the url - page.goto(url) - pv_player: FrameLocator = page.frame_locator("#pv-embed-player") - # expect(pv_player).to_be_visible() # TODO: Need to look into why this fails - pv_player_show_text: str = pv_player.locator(".embed-player-header-top-text").text_content() - assert pv_player_show_text.replace('-', ' ').lower() == show.replace('-', ' ').lower(), \ - f"Podverse player show for {url} says {pv_player_show_text}" - - # TODO figure out how to call this loop async - for url in episodes: - check_title(page, url) - - -@mark.skip("Issue #534 as well as issue with gnome software vs gnome-software") -def test_episode_tag_broken_link_spaces(page: Page, episodes: list[str]): - """ - Checks for tags with spaces in the name. If there is a space in that tag name, it render the tags page to make sure - it exists. - """ - checked_tags: list[str] = [] # List of tag pages that were already tested - for url in episodes: - page.goto(url) - tags: list[str] = [t for t in page.locator(".tag > a").all_text_contents() if ' ' in t] - for tag in tags: - if tag in checked_tags: - continue - # print(f"{tag} at http://localhost:1313{url}") - page.locator(".tag > a", has_text=tag).click() - expect(page.locator("h1.title", has_text=f"Tag: {tag}")).to_be_visible() - checked_tags.append(tag) - page.goto(url) # Go back to episode page to click on the next tag +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 " | <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(page: Page, episodes: list[str]): +def test_episode_emoji_tags(): """ - Check to see if emoji tags are on the episode and renders tag page of that emoji to make sure it exitsts + Emoji tags render a tag page. No sample episode has emoji tags today; add one when #516 is fixed. """ - pass From dafe24771aff51440eec8a95a45b5c167657f166 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Tue, 3 Nov 2026 22:19:00 -0500 Subject: [PATCH 20/36] :test_tube: Add show page tests (hosts, link icons, pagination) --- test/e2e/test_show.py | 135 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 test/e2e/test_show.py diff --git a/test/e2e/test_show.py b/test/e2e/test_show.py new file mode 100644 index 000000000..8ab76d503 --- /dev/null +++ b/test/e2e/test_show.py @@ -0,0 +1,135 @@ +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 From f342b2bba64b6193536fd6c107efc3a72d71776b Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Tue, 3 Nov 2026 22:19:00 -0500 Subject: [PATCH 21/36] :test_tube: Add people, membership, calendar timezone and real 404 tests --- test/e2e/test_404.py | 5 ++- test/e2e/test_calendar.py | 43 ++++++++++++++++++++++++-- test/e2e/test_membership.py | 37 ++++++++++++++++++++++ test/e2e/test_people.py | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 test/e2e/test_membership.py create mode 100644 test/e2e/test_people.py diff --git a/test/e2e/test_404.py b/test/e2e/test_404.py index 9c77b1af6..27707c242 100644 --- a/test/e2e/test_404.py +++ b/test/e2e/test_404.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from pytest import fixture from playwright.sync_api import Page, expect @@ -7,7 +9,8 @@ def setup(page: Page): """ Code here will run for every test in this file """ - page.goto("/404.html") # TODO: Change to random address once #483 is merged + response = page.goto(f"/this-page-does-not-exist-{uuid4()}/") + assert response.status == 404 def test_404_contents(page: Page): diff --git a/test/e2e/test_calendar.py b/test/e2e/test_calendar.py index 462e60789..8be7f4bed 100644 --- a/test/e2e/test_calendar.py +++ b/test/e2e/test_calendar.py @@ -1,5 +1,5 @@ -from pytest import fixture -from playwright.sync_api import Page, expect, FrameLocator, Locator +from pytest import fixture, mark +from playwright.sync_api import Page, expect @fixture(autouse=True) @@ -7,5 +7,42 @@ 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") - page.wait_for_load_state("networkidle") + + +@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_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_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 From cfeee51129bb9d1fa51e23aef12061402a0b39c8 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Tue, 3 Nov 2026 22:32:05 -0500 Subject: [PATCH 22/36] :test_tube: Replace live tests with HLS player tests; drop dead Jupiter.tube fixtures --- test/conftest.py | 32 +-- test/e2e/test_live.py | 199 ++++++++++-------- .../jb-live_sample-live-event.json | 98 --------- 3 files changed, 111 insertions(+), 218 deletions(-) delete mode 100644 test/fixture_files/jb-live_sample-live-event.json diff --git a/test/conftest.py b/test/conftest.py index 9027cad7c..2db3d2fa5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,11 +1,10 @@ from pathlib import Path -from typing import Dict, Generator, List, Tuple, Callable +from typing import Dict, Generator, List, Tuple from pytest import fixture 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 @fixture def get_test_dir() -> Path: @@ -164,30 +163,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/test_live.py b/test/e2e/test_live.py index 2b234c2fa..1c4800215 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -1,91 +1,110 @@ +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, mark +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 + """ + page.goto("/live/") + toggle = page.locator("#toggle-button") + audio_player = page.locator("#audio-player") + video_wrapper = page.locator("#video-player .video-wrapper") + + expect(toggle).to_have_text("Switch to Audio") + expect(audio_player).to_be_hidden() + + toggle.click() + expect(audio_player).to_be_visible() + expect(video_wrapper).to_be_hidden() + expect(toggle).to_have_text("Switch to Video") + + toggle.click() + expect(audio_player).to_be_hidden() + expect(video_wrapper).to_be_visible() + expect(toggle).to_have_text("Switch to Audio") + + +@mark.xfail( + strict=True, + reason="themes/jb/assets/js/live.js sets aria-checked from the mode before the toggle, so it is inverted", +) +def test_live_toggle_aria_checked(page: Page, stream: StreamStub): + """ + The toggle is a role="switch": aria-checked is "true" while the audio player is active + """ + page.goto("/live/") + toggle = page.locator("#toggle-button") + expect(toggle).to_have_attribute("aria-checked", "false") + toggle.click() + expect(toggle).to_have_attribute("aria-checked", "true") + toggle.click() + expect(toggle).to_have_attribute("aria-checked", "false") 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" - } - } - } - ] -} From a92f7827f0c260b6c5b19e9047ba0d05521be4c7 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Wed, 4 Nov 2026 10:29:59 -0500 Subject: [PATCH 23/36] :white_check_mark: Deselect browser copies of only_browser tests instead of skipping them --- test/conftest.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/conftest.py b/test/conftest.py index 2db3d2fa5..f0bc1a0dd 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -6,6 +6,25 @@ from pytest_base_url.plugin import base_url from playwright.sync_api import Playwright, APIRequestContext, Page, Browser +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} + 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: return Path(__file__).parent.absolute() From 7cd471f9aa9892d2426269e2898c37f29d112a64 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Wed, 4 Nov 2026 10:29:59 -0500 Subject: [PATCH 24/36] :test_tube: Sample 3 oldest + 3 newest episodes per show Separate ACTIVE_SAMPLE / ARCHIVED_SAMPLE sizes (shows with active = true) so they can be tuned independently. --- test/e2e/episode_data.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/e2e/episode_data.py b/test/e2e/episode_data.py index 2c43f2120..a607b1e6b 100644 --- a/test/e2e/episode_data.py +++ b/test/e2e/episode_data.py @@ -7,6 +7,8 @@ CONTENT: Path = Path(__file__).parents[2] / "content" CONTENT_SHOW: Path = CONTENT / "show" META_FILES: set[str] = {"_index.md", "subscribe.md"} +ACTIVE_SAMPLE: int = 3 +ARCHIVED_SAMPLE: int = 3 EXTRA_EPISODES: list[tuple[str, str]] = [ ("linux-unplugged", "434"), # 3 hosts and 3 guests @@ -42,14 +44,20 @@ def _published(date: str) -> datetime: 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]: """ - 5 oldest + 5 newest episodes of every show, plus EXTRA_EPISODES (known edge cases) + 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) - selected += by_date[:5] + by_date[-5:] + 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] = {} From 2c42bcde98a9f04d7597e3541d955f061cdbb393 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Wed, 4 Nov 2026 10:29:59 -0500 Subject: [PATCH 25/36] :construction_worker: Run episode tests on PRs unless every change is known-safe Previously episode tests ran only when a PR touched a fixed list of paths, so Hugo/config/CSS/base-layout changes skipped them. Now any path not on the SAFE list runs them. --- .github/workflows/e2e.yml | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5c3b954b5..a59aefec9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -22,17 +22,29 @@ jobs: - 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" ] && ! git diff --name-only \ - ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- \ - content/show content/people content/sponsors \ - themes/jb/layouts/episode themes/jb/layouts/partials/episode \ - themes/jb/layouts/partials/people themes/jb/layouts/partials/sponsor \ - themes/jb/layouts/partials/meta.html \ - test/e2e/test_episode.py test/e2e/episode_data.py test/conftest.py pytest.ini \ - | grep -q .; then - markers="not periodic and not episodes" + 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" From 851ece5c76375a6f66cd7c8dafe0416bf9e76704 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Thu, 5 Nov 2026 23:04:01 -0500 Subject: [PATCH 26/36] :white_check_mark: Expand llms.txt tests to derive expectations from content and cover every show --- test/e2e/episode_data.py | 8 +- test/e2e/test_llms.py | 484 ++++++++++++++++++++++++++------------- 2 files changed, 324 insertions(+), 168 deletions(-) diff --git a/test/e2e/episode_data.py b/test/e2e/episode_data.py index a607b1e6b..0850f622b 100644 --- a/test/e2e/episode_data.py +++ b/test/e2e/episode_data.py @@ -32,7 +32,7 @@ def load_episode(path: Path) -> 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]: +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"])) @@ -44,7 +44,7 @@ def _published(date: str) -> datetime: return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) -def _is_active(show_dir: Path) -> bool: +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 @@ -55,8 +55,8 @@ def sample_episodes() -> list[Episode]: """ 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 + 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] 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$") + ) From 7796423ef6e018c2e8d832f5040758f5e2841a60 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Thu, 5 Nov 2026 23:24:38 -0500 Subject: [PATCH 27/36] :arrow_up: Bump checkout, upload-artifact and test-reporter actions --- .github/workflows/e2e.yml | 9 ++++----- .github/workflows/periodic_test.yml | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a59aefec9..2f09784a5 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,7 @@ 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 @@ -64,14 +62,15 @@ jobs: 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 + 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' From 03b82a8ce182b90ef9e14e92ea17123db4db88c3 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 08:27:45 -0500 Subject: [PATCH 28/36] :white_check_mark: Cover tags, person pages, show lists, subscribe, sponsors, community, picks, feeds and meta --- test/e2e/test_community.py | 163 ++++++++++++++++++++++++++++++++++ test/e2e/test_contact.py | 14 ++- test/e2e/test_meta.py | 106 ++++++++++++++++++++++ test/e2e/test_person_pages.py | 117 ++++++++++++++++++++++++ test/e2e/test_show.py | 56 ++++++++++++ test/e2e/test_sponsors.py | 91 +++++++++++++++++++ test/e2e/test_tags.py | 134 ++++++++++++++++++++++++++++ 7 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 test/e2e/test_community.py create mode 100644 test/e2e/test_meta.py create mode 100644 test/e2e/test_person_pages.py create mode 100644 test/e2e/test_sponsors.py create mode 100644 test/e2e/test_tags.py 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"<title[^>]*>(.*?)", 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 676269ff3..0de07aa77 100644 --- a/test/e2e/test_contact.py +++ b/test/e2e/test_contact.py @@ -1,6 +1,7 @@ +import re from pathlib import Path from pytest import fixture -from playwright.sync_api import Page, FrameLocator, Locator +from playwright.sync_api import Page, FrameLocator, Locator, expect @fixture(autouse=True) @@ -43,3 +44,14 @@ def test_contect_submit_button_visible(page: Page): button_bottom: float = button_location.get("y") + button_location.get("height") 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_meta.py b/test/e2e/test_meta.py new file mode 100644 index 000000000..c015fca83 --- /dev/null +++ b/test/e2e/test_meta.py @@ -0,0 +1,106 @@ +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) + + +MISSING_OGIMAGE = mark.xfail( + strict=True, + reason=f"ogimage in config/_default/params.toml is {OGIMAGE}, which doesn't exist (404 in production too)", +) + + +def _meta_params() -> list: + lup = EPISODES["linux-unplugged"][-1] + return [ + param("/", None, id="home", marks=MISSING_OGIMAGE), + 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", marks=MISSING_OGIMAGE), + param("/community/irc/", None, id="community", marks=MISSING_OGIMAGE), + ] + + +@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_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 index 8ab76d503..e209b9d1f 100644 --- a/test/e2e/test_show.py +++ b/test/e2e/test_show.py @@ -133,3 +133,59 @@ def test_show_pagination(page: Page, show: dict, request): 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..ada9816f4 --- /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.xfail(reason="emoji-only tags get no tag page (issue #516)") + +# (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) From a3bd6eb631c0ad1693a2b17c5fd5c475d47d3f21 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 08:50:42 -0500 Subject: [PATCH 29/36] :test_tube: Sample 5 newest/oldest episodes for active shows, 2 for archived; fix default og:image --- config/_default/params.toml | 2 +- test/e2e/episode_data.py | 4 ++-- test/e2e/test_meta.py | 12 +++--------- 3 files changed, 6 insertions(+), 12 deletions(-) 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/test/e2e/episode_data.py b/test/e2e/episode_data.py index 0850f622b..0d79184e2 100644 --- a/test/e2e/episode_data.py +++ b/test/e2e/episode_data.py @@ -7,8 +7,8 @@ CONTENT: Path = Path(__file__).parents[2] / "content" CONTENT_SHOW: Path = CONTENT / "show" META_FILES: set[str] = {"_index.md", "subscribe.md"} -ACTIVE_SAMPLE: int = 3 -ARCHIVED_SAMPLE: int = 3 +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 diff --git a/test/e2e/test_meta.py b/test/e2e/test_meta.py index c015fca83..e8f0cf08c 100644 --- a/test/e2e/test_meta.py +++ b/test/e2e/test_meta.py @@ -63,20 +63,14 @@ 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) -MISSING_OGIMAGE = mark.xfail( - strict=True, - reason=f"ogimage in config/_default/params.toml is {OGIMAGE}, which doesn't exist (404 in production too)", -) - - def _meta_params() -> list: lup = EPISODES["linux-unplugged"][-1] return [ - param("/", None, id="home", marks=MISSING_OGIMAGE), + 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", marks=MISSING_OGIMAGE), - param("/community/irc/", None, id="community", marks=MISSING_OGIMAGE), + param("/hosts/chris/", None, id="host"), + param("/community/irc/", None, id="community"), ] From 7b7d15346124840380868825c84776b82259f619 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 13:58:23 -0500 Subject: [PATCH 30/36] :bug: Stack people cards one per row below 376px so /people/ doesn't overflow narrow phones --- themes/jb/assets/css/main.sass | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 09b56220936cf74c932d147dc41a958b664d61b8 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 13:58:23 -0500 Subject: [PATCH 31/36] :white_check_mark: Add mobile and tablet e2e tests; screenshot mobile failures and always upload screenshots --- .github/workflows/e2e.yml | 2 + test/conftest.py | 56 +++++++++++++++++-------- test/e2e/test_mobile.py | 86 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 test/e2e/test_mobile.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2f09784a5..6acd635ba 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -62,6 +62,8 @@ jobs: 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 + # 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 diff --git a/test/conftest.py b/test/conftest.py index f0bc1a0dd..4083288db 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,11 +1,35 @@ +import re from pathlib import Path from typing import Dict, Generator, List, Tuple -from pytest import fixture +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, 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, @@ -15,6 +39,9 @@ def pytest_collection_modifyitems(config, items): 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) @@ -46,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 @@ -67,20 +88,19 @@ def mobile_device_tuple( base_url: base_url, ) -> Tuple[Page,str]: - params = dict(playwright.devices[request.param]) - if browser.browser_type.name == "firefox": - # firefox doesn't support is_mobile: https://github.com/microsoft/playwright/issues/16622 - del params['is_mobile'] - context = browser.new_context( base_url=base_url, - **params + **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 diff --git a/test/e2e/test_mobile.py b/test/e2e/test_mobile.py new file mode 100644 index 000000000..44e2a4fa3 --- /dev/null +++ b/test/e2e/test_mobile.py @@ -0,0 +1,86 @@ +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(contact).to_be_hidden() + + burger.click() + expect(menu).to_have_class("navbar-menu is-active") + expect(contact).to_be_visible() + expect(menu.locator('a.navbar-item[href$="/show/linux-unplugged/"]')).to_be_visible() + + burger.click() + 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 From 9d26cc86fdf764071ba2a7bdda36cd908082c1a6 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 16:36:09 -0500 Subject: [PATCH 32/36] :white_check_mark: Test the home show slider: slides, next/prev buttons and mouse drag --- test/e2e/test_home.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/e2e/test_home.py b/test/e2e/test_home.py index e624c8898..c2a4cb038 100644 --- a/test/e2e/test_home.py +++ b/test/e2e/test_home.py @@ -1,6 +1,11 @@ +import re +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): @@ -34,3 +39,65 @@ def test_home_pagination(page: Page): assert "/page/2" in page.url first_card_second_page = page.locator('.card').nth(0).text_content assert first_card != first_card_second_page + + +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")) From 158727f7dbe41da1c506af98e8f9009a61b09f91 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 16:51:49 -0500 Subject: [PATCH 33/36] :bug: Let a held drag move the home show slider (longSwipesRatio 0.1) --- themes/jb/assets/js/main.js | 3 +++ 1 file changed, 3 insertions(+) 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, From 1ebdcd90abfa5719f7587251fc1d480955510ce8 Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 18:08:38 -0500 Subject: [PATCH 34/36] :wheelchair: Update the navbar burger's aria-expanded when the menu opens and closes --- test/e2e/test_mobile.py | 3 +++ themes/jb/assets/js/navigation.js | 1 + 2 files changed, 4 insertions(+) diff --git a/test/e2e/test_mobile.py b/test/e2e/test_mobile.py index 44e2a4fa3..10ac0b340 100644 --- a/test/e2e/test_mobile.py +++ b/test/e2e/test_mobile.py @@ -46,14 +46,17 @@ def test_mobile_burger_menu(mobile_device_tuple: Tuple[Page, str]): 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() 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')); }); }); From ceb008a2bfb162b7bb816b1ee320c60dbb77520c Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 18:08:38 -0500 Subject: [PATCH 35/36] :wheelchair: Make the live audio/video toggle a plain button instead of an on/off switch --- test/e2e/test_live.py | 32 +++++++------------------------- themes/jb/assets/js/live.js | 1 - themes/jb/layouts/live/list.html | 2 +- 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/test/e2e/test_live.py b/test/e2e/test_live.py index 1c4800215..66050f67b 100644 --- a/test/e2e/test_live.py +++ b/test/e2e/test_live.py @@ -1,7 +1,7 @@ import re from pathlib import Path -from pytest import fixture, mark +from pytest import fixture from playwright.sync_api import Page, Route, expect CONFIG: str = (Path(__file__).parents[2] / "config.toml").read_text() @@ -72,39 +72,21 @@ def test_live_online_hides_overlay(page: Page, stream: StreamStub): def test_live_toggle_audio_video(page: Page, stream: StreamStub): """ - The toggle button swaps between the video and audio players and relabels itself + 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/") - toggle = page.locator("#toggle-button") audio_player = page.locator("#audio-player") video_wrapper = page.locator("#video-player .video-wrapper") - expect(toggle).to_have_text("Switch to Audio") expect(audio_player).to_be_hidden() + expect(video_wrapper).to_be_visible() - toggle.click() + page.get_by_role("button", name="Switch to Audio", exact=True).click() expect(audio_player).to_be_visible() expect(video_wrapper).to_be_hidden() - expect(toggle).to_have_text("Switch to Video") - toggle.click() + 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(toggle).to_have_text("Switch to Audio") - - -@mark.xfail( - strict=True, - reason="themes/jb/assets/js/live.js sets aria-checked from the mode before the toggle, so it is inverted", -) -def test_live_toggle_aria_checked(page: Page, stream: StreamStub): - """ - The toggle is a role="switch": aria-checked is "true" while the audio player is active - """ - page.goto("/live/") - toggle = page.locator("#toggle-button") - expect(toggle).to_have_attribute("aria-checked", "false") - toggle.click() - expect(toggle).to_have_attribute("aria-checked", "true") - toggle.click() - expect(toggle).to_have_attribute("aria-checked", "false") + expect(page.get_by_role("button", name="Switch to Audio", exact=True)).to_be_visible() 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/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"> From 6253c2e80836f81d3816e2df54f7a725f566d5fb Mon Sep 17 00:00:00 2001 From: Ryan Goodwin <goodwinryan2@gmail.com> Date: Fri, 6 Nov 2026 18:12:22 -0500 Subject: [PATCH 36/36] :see_no_evil: Skip emoji tag tests until issue #516 is fixed --- test/e2e/test_tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/test_tags.py b/test/e2e/test_tags.py index ada9816f4..c0a2b1d97 100644 --- a/test/e2e/test_tags.py +++ b/test/e2e/test_tags.py @@ -31,7 +31,7 @@ def _episodes_by_tag() -> dict[str, list[str]]: "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.xfail(reason="emoji-only tags get no tag page (issue #516)") +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]] = [