feat: comprehensive Playwright E2E test suite #7

Merged
mark merged 3 commits from tests/e2e into main 2026-02-28 20:25:30 +00:00
12 changed files with 643 additions and 8 deletions
Showing only changes of commit 9d323d2040 - Show all commits

View File

@@ -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 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 - 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) - name: Tailwind build (assert generated diff is clean)
run: | run: |
@@ -67,6 +67,71 @@ jobs:
if: always() if: always()
run: docker image rm -f "$CI_IMAGE" || true 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: nightly-e2e:
if: github.event_name == 'schedule' if: github.event_name == 'schedule'
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -96,6 +161,10 @@ jobs:
docker run -d --name nightly-e2e --network container:nightly-postgres \ docker run -d --name nightly-e2e --network container:nightly-postgres \
-e SECRET_KEY=ci-secret-key \ -e SECRET_KEY=ci-secret-key \
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \ -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" \ "$CI_IMAGE" \
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000" 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 for i in $(seq 1 40); do
@@ -106,11 +175,11 @@ jobs:
done done
docker logs nightly-e2e || true docker logs nightly-e2e || true
exit 1 exit 1
- name: Run nightly Playwright journey - name: Run Playwright E2E tests
run: | run: |
docker exec nightly-e2e python -m playwright install chromium docker exec nightly-e2e python -m playwright install chromium
docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 nightly-e2e \ 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 - name: Remove nightly container
if: always() if: always()
run: | run: |

View File

@@ -1,21 +1,25 @@
from __future__ import annotations from __future__ import annotations
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from taggit.models import Tag
from wagtail.models import Page, Site from wagtail.models import Page, Site
from apps.authors.models import Author 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): class Command(BaseCommand):
help = "Seed deterministic content for nightly Playwright E2E checks." help = "Seed deterministic content for E2E checks."
def handle(self, *args, **options): def handle(self, *args, **options):
import datetime
root = Page.get_first_root_node() root = Page.get_first_root_node()
home = HomePage.objects.child_of(root).first() home = HomePage.objects.child_of(root).first()
if home is None: 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) root.add_child(instance=home)
home.save_revision().publish() 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() article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
if article is None: if article is None:
article = ArticlePage( article = ArticlePage(
@@ -46,6 +51,67 @@ class Command(BaseCommand):
article_index.add_child(instance=article) article_index.add_child(instance=article)
article.save_revision().publish() 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", "<p>This article is tagged with AI Tools.</p>")],
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", "<p>Comments are disabled on this one.</p>")],
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="<p>We benchmark, so you don't have to.</p>",
)
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="<p>We take your privacy seriously.</p>",
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( site, _ = Site.objects.get_or_create(
hostname="127.0.0.1", hostname="127.0.0.1",
port=8000, port=8000,
@@ -60,4 +126,4 @@ class Command(BaseCommand):
site.site_name = "No Hype AI" site.site_name = "No Hype AI"
site.save() site.save()
self.stdout.write(self.style.SUCCESS("Seeded nightly E2E content.")) self.stdout.write(self.style.SUCCESS("Seeded E2E content."))

View File

@@ -2,7 +2,7 @@ import pytest
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import CommandError 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 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"): with pytest.raises(CommandError, match="empty summary"):
call_command("check_content_integrity") 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

0
e2e/__init__.py Normal file
View File

40
e2e/conftest.py Normal file
View File

@@ -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()

View File

@@ -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")

59
e2e/test_articles.py Normal file
View File

@@ -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()

53
e2e/test_comments.py Normal file
View File

@@ -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)

View File

@@ -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)

61
e2e/test_feeds.py Normal file
View File

@@ -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 "<rss" in content or "<feed" in content or "&lt;rss" in content or "&lt;feed" in content, (
"RSS feed response must contain a <rss or <feed root element"
)
@pytest.mark.e2e
def test_rss_feed_contains_seeded_article(page: Page, base_url: str) -> 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 "&lt;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 "<rss" in content or "<feed" in content or "&lt;rss" in content or "&lt;feed" in content

54
e2e/test_home.py Normal file
View File

@@ -0,0 +1,54 @@
"""E2E tests for the home page."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
def test_homepage_title_contains_brand(page: Page, base_url: str) -> 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 <html>"
@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()

62
e2e/test_newsletter.py Normal file
View File

@@ -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)