diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 360e702..803847b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" mypy apps config - name: Pytest - run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" pytest + run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" pytest --ignore=e2e - name: Tailwind build (assert generated diff is clean) run: | @@ -67,6 +67,71 @@ jobs: if: always() run: docker image rm -f "$CI_IMAGE" || true + pr-e2e: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + env: + CI_IMAGE: nohype-ci-e2e:${{ github.run_id }} + steps: + - uses: actions/checkout@v4 + + - name: Build + run: docker build -t "$CI_IMAGE" . + + - name: Start PostgreSQL + run: | + docker run -d --name pr-e2e-postgres \ + -e POSTGRES_DB=nohype \ + -e POSTGRES_USER=nohype \ + -e POSTGRES_PASSWORD=nohype \ + postgres:16-alpine + for i in $(seq 1 30); do + if docker exec pr-e2e-postgres pg_isready -U nohype -d nohype >/dev/null; then + exit 0 + fi + sleep 2 + done + docker logs pr-e2e-postgres || true + exit 1 + + - name: Start app with seeded content + run: | + docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \ + -e SECRET_KEY=ci-secret-key \ + -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \ + -e CONSENT_POLICY_VERSION=1 \ + -e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \ + -e DEFAULT_FROM_EMAIL=hello@nohypeai.com \ + -e NEWSLETTER_PROVIDER=buttondown \ + "$CI_IMAGE" \ + sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000" + for i in $(seq 1 40); do + if docker exec pr-e2e-app curl -fsS http://127.0.0.1:8000/ >/dev/null; then + exit 0 + fi + sleep 2 + done + docker logs pr-e2e-app || true + exit 1 + + - name: Install Playwright browsers + run: docker exec pr-e2e-app python -m playwright install chromium + + - name: Run E2E tests + run: | + docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 pr-e2e-app \ + pytest e2e/ -o addopts='' -q --tb=short + + - name: Remove containers + if: always() + run: | + docker rm -f pr-e2e-app || true + docker rm -f pr-e2e-postgres || true + + - name: Remove CI image + if: always() + run: docker image rm -f "$CI_IMAGE" || true + nightly-e2e: if: github.event_name == 'schedule' runs-on: ubuntu-latest @@ -96,6 +161,10 @@ jobs: docker run -d --name nightly-e2e --network container:nightly-postgres \ -e SECRET_KEY=ci-secret-key \ -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \ + -e CONSENT_POLICY_VERSION=1 \ + -e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \ + -e DEFAULT_FROM_EMAIL=hello@nohypeai.com \ + -e NEWSLETTER_PROVIDER=buttondown \ "$CI_IMAGE" \ sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000" for i in $(seq 1 40); do @@ -106,11 +175,11 @@ jobs: done docker logs nightly-e2e || true exit 1 - - name: Run nightly Playwright journey + - name: Run Playwright E2E tests run: | docker exec nightly-e2e python -m playwright install chromium docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 nightly-e2e \ - pytest -o addopts='' apps/core/tests/test_nightly_e2e_playwright.py -q + pytest e2e/ apps/core/tests/test_nightly_e2e_playwright.py -o addopts='' -q --tb=short - name: Remove nightly container if: always() run: | diff --git a/apps/core/management/commands/seed_e2e_content.py b/apps/core/management/commands/seed_e2e_content.py index 82d1f06..d33e21f 100644 --- a/apps/core/management/commands/seed_e2e_content.py +++ b/apps/core/management/commands/seed_e2e_content.py @@ -1,21 +1,25 @@ from __future__ import annotations from django.core.management.base import BaseCommand +from taggit.models import Tag from wagtail.models import Page, Site from apps.authors.models import Author -from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage +from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata +from apps.legal.models import LegalIndexPage, LegalPage class Command(BaseCommand): - help = "Seed deterministic content for nightly Playwright E2E checks." + help = "Seed deterministic content for E2E checks." def handle(self, *args, **options): + import datetime + root = Page.get_first_root_node() home = HomePage.objects.child_of(root).first() if home is None: - home = HomePage(title="Nightly Home", slug="nightly-home") + home = HomePage(title="No Hype AI", slug="nohype-home") root.add_child(instance=home) home.save_revision().publish() @@ -33,6 +37,7 @@ class Command(BaseCommand): }, ) + # Primary article — comments enabled, used by nightly journey test article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first() if article is None: article = ArticlePage( @@ -46,6 +51,67 @@ class Command(BaseCommand): article_index.add_child(instance=article) article.save_revision().publish() + # Tagged article — used by tag-filter E2E tests + tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools") + TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"}) + tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first() + if tagged_article is None: + tagged_article = ArticlePage( + title="Tagged Article", + slug="e2e-tagged-article", + author=author, + summary="An article with tags for E2E filter tests.", + body=[("rich_text", "

This article is tagged with AI Tools.

")], + comments_enabled=True, + ) + article_index.add_child(instance=tagged_article) + tagged_article.save_revision().publish() + tagged_article.tags.add(tag) + tagged_article.save() + + # Third article — comments disabled + no_comments_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-no-comments").first() + if no_comments_article is None: + no_comments_article = ArticlePage( + title="No Comments Article", + slug="e2e-no-comments", + author=author, + summary="An article with comments disabled.", + body=[("rich_text", "

Comments are disabled on this one.

")], + comments_enabled=False, + ) + article_index.add_child(instance=no_comments_article) + no_comments_article.save_revision().publish() + + # About page + if not AboutPage.objects.child_of(home).filter(slug="about").exists(): + about = AboutPage( + title="About", + slug="about", + mission_statement="Honest AI coding tool reviews for developers.", + body="

We benchmark, so you don't have to.

", + ) + home.add_child(instance=about) + about.save_revision().publish() + + # Legal pages + legal_index = LegalIndexPage.objects.child_of(home).filter(slug="legal").first() + if legal_index is None: + legal_index = LegalIndexPage(title="Legal", slug="legal") + home.add_child(instance=legal_index) + legal_index.save_revision().publish() + + if not LegalPage.objects.child_of(legal_index).filter(slug="privacy-policy").exists(): + privacy = LegalPage( + title="Privacy Policy", + slug="privacy-policy", + body="

We take your privacy seriously.

", + last_updated=datetime.date.today(), + show_in_footer=True, + ) + legal_index.add_child(instance=privacy) + privacy.save_revision().publish() + site, _ = Site.objects.get_or_create( hostname="127.0.0.1", port=8000, @@ -60,4 +126,4 @@ class Command(BaseCommand): site.site_name = "No Hype AI" site.save() - self.stdout.write(self.style.SUCCESS("Seeded nightly E2E content.")) + self.stdout.write(self.style.SUCCESS("Seeded E2E content.")) diff --git a/apps/core/tests/test_commands.py b/apps/core/tests/test_commands.py index b7fedb8..381064c 100644 --- a/apps/core/tests/test_commands.py +++ b/apps/core/tests/test_commands.py @@ -2,7 +2,7 @@ import pytest from django.core.management import call_command from django.core.management.base import CommandError -from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage from apps.blog.tests.factories import AuthorFactory @@ -28,3 +28,29 @@ def test_check_content_integrity_fails_for_blank_summary(home_page): with pytest.raises(CommandError, match="empty summary"): call_command("check_content_integrity") + + +@pytest.mark.django_db +def test_seed_e2e_content_creates_expected_pages(): + call_command("seed_e2e_content") + + assert ArticlePage.objects.filter(slug="nightly-playwright-journey").exists() + assert ArticlePage.objects.filter(slug="e2e-tagged-article").exists() + assert ArticlePage.objects.filter(slug="e2e-no-comments").exists() + assert AboutPage.objects.filter(slug="about").exists() + + # Tagged article must carry the seeded tag + tagged = ArticlePage.objects.get(slug="e2e-tagged-article") + assert tagged.tags.filter(slug="ai-tools").exists() + + # No-comments article must have comments disabled + no_comments = ArticlePage.objects.get(slug="e2e-no-comments") + assert no_comments.comments_enabled is False + + +@pytest.mark.django_db +def test_seed_e2e_content_is_idempotent(): + """Running the command twice must not raise or create duplicates.""" + call_command("seed_e2e_content") + call_command("seed_e2e_content") + assert ArticlePage.objects.filter(slug="nightly-playwright-journey").count() == 1 diff --git a/e2e/__init__.py b/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/e2e/conftest.py b/e2e/conftest.py new file mode 100644 index 0000000..a2b9ade --- /dev/null +++ b/e2e/conftest.py @@ -0,0 +1,40 @@ +"""Shared fixtures for E2E Playwright tests. + +All tests in this directory require a running application server pointed to by +the E2E_BASE_URL environment variable. Tests are automatically skipped when +the variable is absent, making them safe to collect in any environment. +""" + +from __future__ import annotations + +import os +from collections.abc import Generator + +import pytest +from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright + + +@pytest.fixture(scope="session") +def base_url() -> str: + url = os.getenv("E2E_BASE_URL", "").rstrip("/") + if not url: + pytest.skip("E2E_BASE_URL not set – start a server and export E2E_BASE_URL to run E2E tests") + return url + + +@pytest.fixture(scope="session") +def _browser(base_url: str) -> Generator[Browser, None, None]: # noqa: ARG001 + """Session-scoped Chromium instance (headless).""" + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() + + +@pytest.fixture() +def page(_browser: Browser) -> Generator[Page, None, None]: + """Fresh browser context + page per test — no shared state between tests.""" + ctx: BrowserContext = _browser.new_context() + pg: Page = ctx.new_page() + yield pg + ctx.close() diff --git a/e2e/test_article_detail.py b/e2e/test_article_detail.py new file mode 100644 index 0000000..1e07d69 --- /dev/null +++ b/e2e/test_article_detail.py @@ -0,0 +1,73 @@ +"""E2E tests for article detail pages.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + +ARTICLE_SLUG = "nightly-playwright-journey" + + +def _go_to_article(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle") + + +@pytest.mark.e2e +def test_article_title_visible(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + h1 = page.get_by_role("heading", level=1) + expect(h1).to_be_visible() + assert h1.inner_text().strip() != "" + + +@pytest.mark.e2e +def test_article_read_time_visible(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + # Read time is rendered as "N min read" + expect(page.get_by_text("min read")).to_be_visible() + + +@pytest.mark.e2e +def test_article_share_section_present(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + share_section = page.get_by_role("region", name="Share this article") + expect(share_section).to_be_visible() + expect(share_section.get_by_role("link", name="Share on X")).to_be_visible() + expect(share_section.get_by_role("link", name="Share on LinkedIn")).to_be_visible() + expect(share_section.get_by_role("button", name="Copy link")).to_be_visible() + + +@pytest.mark.e2e +def test_article_comments_section_present(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + # The article has comments_enabled=True + expect(page.get_by_role("heading", name="Comments")).to_be_visible() + expect(page.get_by_role("button", name="Post comment")).to_be_visible() + + +@pytest.mark.e2e +def test_article_newsletter_aside_present(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + # There's a Newsletter aside within the article page + aside = page.locator("aside") + expect(aside).to_be_visible() + expect(aside.locator('input[type="email"]')).to_be_visible() + + +@pytest.mark.e2e +def test_article_related_section_present(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + # Related section heading + expect(page.get_by_role("heading", name="Related")).to_be_visible() + + +@pytest.mark.e2e +def test_copy_link_button_updates_text(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + copy_btn = page.get_by_role("button", name="Copy link") + expect(copy_btn).to_be_visible() + # Grant clipboard permission and click + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + copy_btn.click() + # Button text should change to "Copied" after click + expect(copy_btn).to_have_text("Copied") diff --git a/e2e/test_articles.py b/e2e/test_articles.py new file mode 100644 index 0000000..71fb854 --- /dev/null +++ b/e2e/test_articles.py @@ -0,0 +1,59 @@ +"""E2E tests for the article index page.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.e2e +def test_article_index_loads(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/", wait_until="networkidle") + expect(page.get_by_role("heading", level=1)).to_be_visible() + # At least one article card must be present after seeding + expect(page.locator("main article").first).to_be_visible() + + +@pytest.mark.e2e +def test_tag_filter_shows_tagged_articles(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/", wait_until="networkidle") + # The seeded "AI Tools" tag link must be present + tag_link = page.get_by_role("link", name="AI Tools") + expect(tag_link).to_be_visible() + tag_link.click() + page.wait_for_load_state("networkidle") + + # URL should now contain ?tag=ai-tools + assert "tag=ai-tools" in page.url + + # The tagged article must appear; no-tag articles may be absent + expect(page.get_by_text("Tagged Article")).to_be_visible() + + +@pytest.mark.e2e +def test_all_tag_clears_filter(page: Page, base_url: str) -> None: + # Start with the tag filter applied + page.goto(f"{base_url}/articles/?tag=ai-tools", wait_until="networkidle") + + # Clicking "All" should return to unfiltered list + page.get_by_role("link", name="All").click() + page.wait_for_load_state("networkidle") + assert "tag=" not in page.url + # All seeded articles should now be visible + expect(page.locator("main article").first).to_be_visible() + + +@pytest.mark.e2e +def test_article_card_navigates_to_detail(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/", wait_until="networkidle") + first_link = page.locator("main article a").first + expect(first_link).to_be_visible() + + href = first_link.get_attribute("href") + assert href, "Article card must have an href" + + first_link.click() + page.wait_for_load_state("networkidle") + + # We should be on an article detail page + expect(page.get_by_role("heading", level=1)).to_be_visible() diff --git a/e2e/test_comments.py b/e2e/test_comments.py new file mode 100644 index 0000000..dc38ae4 --- /dev/null +++ b/e2e/test_comments.py @@ -0,0 +1,53 @@ +"""E2E tests for the comment submission flow.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + +ARTICLE_SLUG = "nightly-playwright-journey" + + +def _go_to_article(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle") + + +@pytest.mark.e2e +def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + + # Fill the main comment form (not a reply form) + form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment")) + form.locator('input[name="author_name"]').fill("E2E Tester") + form.locator('input[name="author_email"]').fill("e2e@example.com") + form.locator('textarea[name="body"]').fill("This is a test comment from Playwright.") + form.get_by_role("button", name="Post comment").click() + + # Successful submission redirects back to the article with ?commented=1 + page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000) + assert "commented=1" in page.url + + +@pytest.mark.e2e +def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + + form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment")) + form.locator('input[name="author_name"]').fill("E2E Tester") + form.locator('input[name="author_email"]').fill("e2e@example.com") + form.locator('textarea[name="body"]').fill(" ") # whitespace-only body + form.get_by_role("button", name="Post comment").click() + page.wait_for_load_state("networkidle") + + # The page re-renders with the error summary visible + expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible() + # URL must NOT have ?commented=1 — form was not accepted + assert "commented=1" not in page.url + + +@pytest.mark.e2e +def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None: + """Article with comments_enabled=False must not show the comments section.""" + page.goto(f"{base_url}/articles/e2e-no-comments/", wait_until="networkidle") + expect(page.get_by_role("heading", name="Comments")).to_have_count(0) + expect(page.get_by_role("button", name="Post comment")).to_have_count(0) diff --git a/e2e/test_cookie_consent.py b/e2e/test_cookie_consent.py new file mode 100644 index 0000000..3cfb887 --- /dev/null +++ b/e2e/test_cookie_consent.py @@ -0,0 +1,72 @@ +"""E2E tests for the cookie consent banner.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +def _open_fresh_page(page: Page, url: str) -> None: + """Navigate to URL with no existing consent cookie (fresh context guarantees this).""" + page.goto(url, wait_until="networkidle") + + +@pytest.mark.e2e +def test_banner_visible_on_first_visit(page: Page, base_url: str) -> None: + _open_fresh_page(page, f"{base_url}/") + expect(page.locator("#cookie-banner")).to_be_visible() + + +@pytest.mark.e2e +def test_accept_all_dismisses_banner(page: Page, base_url: str) -> None: + _open_fresh_page(page, f"{base_url}/") + banner = page.locator("#cookie-banner") + expect(banner).to_be_visible() + page.get_by_role("button", name="Accept all").first.click() + page.wait_for_load_state("networkidle") + expect(banner).to_have_count(0) + + +@pytest.mark.e2e +def test_reject_all_dismisses_banner(page: Page, base_url: str) -> None: + _open_fresh_page(page, f"{base_url}/") + banner = page.locator("#cookie-banner") + expect(banner).to_be_visible() + page.get_by_role("button", name="Reject all").first.click() + page.wait_for_load_state("networkidle") + expect(banner).to_have_count(0) + + +@pytest.mark.e2e +def test_granular_preferences_save_dismisses_banner(page: Page, base_url: str) -> None: + _open_fresh_page(page, f"{base_url}/") + banner = page.locator("#cookie-banner") + expect(banner).to_be_visible() + + # Expand the "Manage preferences" details section + banner.get_by_role("group").first.click() + # Or use the summary text + page.get_by_text("Manage preferences").click() + + # Check analytics, leave advertising unchecked + analytics_checkbox = page.locator('input[name="analytics"]') + expect(analytics_checkbox).to_be_visible() + analytics_checkbox.check() + + # Submit granular preferences + page.get_by_role("button", name="Save preferences").click() + page.wait_for_load_state("networkidle") + expect(banner).to_have_count(0) + + +@pytest.mark.e2e +def test_banner_absent_after_consent_cookie_set(page: Page, base_url: str) -> None: + """After accepting consent, subsequent page loads must not show the banner.""" + _open_fresh_page(page, f"{base_url}/") + # Accept consent + page.get_by_role("button", name="Accept all").first.click() + page.wait_for_load_state("networkidle") + + # Navigate to another page in the same context — cookie should persist + page.goto(f"{base_url}/articles/", wait_until="networkidle") + expect(page.locator("#cookie-banner")).to_have_count(0) diff --git a/e2e/test_feeds.py b/e2e/test_feeds.py new file mode 100644 index 0000000..d9b8689 --- /dev/null +++ b/e2e/test_feeds.py @@ -0,0 +1,61 @@ +"""E2E tests for RSS feed, sitemap, and robots.txt.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page + + +@pytest.mark.e2e +def test_rss_feed_returns_valid_xml(page: Page, base_url: str) -> None: + response = page.goto(f"{base_url}/feed/", wait_until="networkidle") + assert response is not None + assert response.status == 200 + content = page.content() + assert " None: + response = page.goto(f"{base_url}/feed/", wait_until="networkidle") + assert response is not None and response.status == 200 + content = page.content() + assert "Nightly Playwright Journey" in content, "Seeded article title must appear in the feed" + + +@pytest.mark.e2e +def test_sitemap_returns_valid_xml(page: Page, base_url: str) -> None: + response = page.goto(f"{base_url}/sitemap.xml", wait_until="networkidle") + assert response is not None + assert response.status == 200 + content = page.content() + assert "urlset" in content or "<urlset" in content, "Sitemap must contain urlset element" + + +@pytest.mark.e2e +def test_sitemap_contains_article_url(page: Page, base_url: str) -> None: + response = page.goto(f"{base_url}/sitemap.xml", wait_until="networkidle") + assert response is not None and response.status == 200 + content = page.content() + assert "nightly-playwright-journey" in content, "Seeded article URL must appear in sitemap" + + +@pytest.mark.e2e +def test_robots_txt_is_accessible(page: Page, base_url: str) -> None: + response = page.goto(f"{base_url}/robots.txt", wait_until="networkidle") + assert response is not None + assert response.status == 200 + content = page.content() + assert "User-agent" in content, "robots.txt must contain User-agent directive" + + +@pytest.mark.e2e +def test_tag_rss_feed(page: Page, base_url: str) -> None: + """Tag-specific feed must return 200 and valid XML for a seeded tag.""" + response = page.goto(f"{base_url}/feed/tag/ai-tools/", wait_until="networkidle") + assert response is not None + assert response.status == 200 + content = page.content() + assert " None: + page.goto(f"{base_url}/", wait_until="networkidle") + expect(page).to_have_title(lambda t: "No Hype AI" in t or len(t) > 0) + # At minimum the page must load without error + assert page.url.startswith(base_url) + + +@pytest.mark.e2e +def test_nav_links_present(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/", wait_until="networkidle") + nav = page.locator("nav") + expect(nav.get_by_role("link", name="Home")).to_be_visible() + expect(nav.get_by_role("link", name="Articles")).to_be_visible() + expect(nav.get_by_role("link", name="About")).to_be_visible() + + +@pytest.mark.e2e +def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/", wait_until="networkidle") + toggle = page.get_by_role("button", name="Toggle theme") + expect(toggle).to_be_visible() + # Initial state: html may or may not have dark class + html = page.locator("html") + before = "dark" in (html.get_attribute("class") or "") + toggle.click() + after = "dark" in (html.get_attribute("class") or "") + assert before != after, "Theme toggle must flip the dark class on " + + +@pytest.mark.e2e +def test_newsletter_form_in_nav(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/", wait_until="networkidle") + # The nav contains a newsletter form with an email input + nav = page.locator("nav") + expect(nav.locator('input[type="email"]')).to_be_visible() + expect(nav.get_by_role("button", name="Subscribe")).to_be_visible() + + +@pytest.mark.e2e +def test_home_shows_articles(page: Page, base_url: str) -> None: + """Latest articles section is populated after seeding.""" + page.goto(f"{base_url}/", wait_until="networkidle") + # Seeded content means there should be at least one article card link + article_links = page.locator("main article a") + expect(article_links.first).to_be_visible() diff --git a/e2e/test_newsletter.py b/e2e/test_newsletter.py new file mode 100644 index 0000000..00326b5 --- /dev/null +++ b/e2e/test_newsletter.py @@ -0,0 +1,62 @@ +"""E2E tests for the newsletter subscription form.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +def _nav_newsletter_form(page: Page): + return page.locator("nav").locator("form[data-newsletter-form]") + + +@pytest.mark.e2e +def test_subscribe_valid_email_shows_confirmation(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/", wait_until="networkidle") + form = _nav_newsletter_form(page) + form.locator('input[type="email"]').fill("playwright-test@example.com") + form.get_by_role("button", name="Subscribe").click() + + # JS sets the data-newsletter-message text on success + message = form.locator("[data-newsletter-message]") + expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000) + + +@pytest.mark.e2e +def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/", wait_until="networkidle") + form = _nav_newsletter_form(page) + form.locator('input[type="email"]').fill("not-an-email") + form.get_by_role("button", name="Subscribe").click() + + message = form.locator("[data-newsletter-message]") + expect(message).to_have_text("Please enter a valid email.", timeout=5_000) + + +@pytest.mark.e2e +def test_subscribe_from_article_aside(page: Page, base_url: str) -> None: + """Newsletter form embedded in the article aside also works.""" + page.goto(f"{base_url}/articles/nightly-playwright-journey/", wait_until="networkidle") + aside_form = page.locator("aside").locator("form[data-newsletter-form]") + aside_form.locator('input[type="email"]').fill("aside-test@example.com") + aside_form.get_by_role("button", name="Subscribe").click() + + message = aside_form.locator("[data-newsletter-message]") + expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000) + + +@pytest.mark.e2e +def test_subscribe_duplicate_email_still_shows_confirmation(page: Page, base_url: str) -> None: + """Submitting the same address twice must not expose an error to the user.""" + email = "dupe-e2e@example.com" + page.goto(f"{base_url}/", wait_until="networkidle") + form = _nav_newsletter_form(page) + form.locator('input[type="email"]').fill(email) + form.get_by_role("button", name="Subscribe").click() + message = form.locator("[data-newsletter-message]") + expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000) + + # Second submission — form resets after first, so fill again + form.locator('input[type="email"]').fill(email) + form.get_by_role("button", name="Subscribe").click() + expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)