Merge pull request 'feat: comprehensive Playwright E2E test suite' (#7) from tests/e2e into main
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -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,70 @@ 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 \
|
||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
||||
-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 \
|
||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
||||
"$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: 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
|
||||
@@ -94,8 +158,14 @@ jobs:
|
||||
- name: Start dev server with seeded content
|
||||
run: |
|
||||
docker run -d --name nightly-e2e --network container:nightly-postgres \
|
||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
||||
-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 \
|
||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
||||
"$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 +176,10 @@ 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: |
|
||||
|
||||
@@ -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,71 @@ 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", "<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)
|
||||
# Explicitly persist False after add_child (which internally calls save())
|
||||
# to guard against any field reset in the page tree insertion path.
|
||||
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
|
||||
no_comments_article.comments_enabled = False
|
||||
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(
|
||||
hostname="127.0.0.1",
|
||||
port=8000,
|
||||
@@ -59,5 +129,7 @@ class Command(BaseCommand):
|
||||
site.is_default_site = True
|
||||
site.site_name = "No Hype AI"
|
||||
site.save()
|
||||
# Remove any other conflicting default-site entries left by test fixtures
|
||||
Site.objects.exclude(pk=site.pk).filter(is_default_site=True).update(is_default_site=False)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded nightly E2E content."))
|
||||
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
python manage.py runserver 0.0.0.0:8000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro
|
||||
ports:
|
||||
- "8035:8000"
|
||||
environment:
|
||||
@@ -21,6 +22,7 @@ services:
|
||||
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
||||
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
||||
NEWSLETTER_PROVIDER: buttondown
|
||||
PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-tools/browsers
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
0
e2e/__init__.py
Normal file
0
e2e/__init__.py
Normal file
57
e2e/conftest.py
Normal file
57
e2e/conftest.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""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.
|
||||
|
||||
Clipboard permissions are pre-granted so copy-link and similar interactions
|
||||
work in headless Chromium without triggering the permissions dialog.
|
||||
"""
|
||||
ctx: BrowserContext = _browser.new_context(
|
||||
permissions=["clipboard-read", "clipboard-write"],
|
||||
)
|
||||
# Polyfill clipboard in environments where the native API is unavailable
|
||||
# (e.g. non-HTTPS Docker CI). The polyfill stores writes in a variable so
|
||||
# the JS success path still runs and button text updates as expected.
|
||||
ctx.add_init_script("""
|
||||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
""")
|
||||
pg: Page = ctx.new_page()
|
||||
yield pg
|
||||
ctx.close()
|
||||
72
e2e/test_article_detail.py
Normal file
72
e2e/test_article_detail.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""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", exact=True)).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.locator("[data-copy-link]")
|
||||
expect(copy_btn).to_be_visible()
|
||||
# Force-override clipboard so writeText always resolves, even in non-HTTPS headless context
|
||||
page.evaluate("navigator.clipboard.writeText = () => Promise.resolve()")
|
||||
copy_btn.click()
|
||||
expect(copy_btn).to_have_text("Copied")
|
||||
59
e2e/test_articles.py
Normal file
59
e2e/test_articles.py
Normal 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()
|
||||
59
e2e/test_comments.py
Normal file
59
e2e/test_comments.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""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."""
|
||||
response = page.goto(f"{base_url}/articles/e2e-no-comments/", wait_until="networkidle")
|
||||
assert response is not None and response.status == 200, (
|
||||
f"Expected 200 for e2e-no-comments article, got {response and response.status}"
|
||||
)
|
||||
# Confirm we're on the right page
|
||||
expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article")
|
||||
# Comments section must be absent — exact=True prevents matching "No Comments Article" h1
|
||||
expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0)
|
||||
expect(page.get_by_role("button", name="Post comment")).to_have_count(0)
|
||||
70
e2e/test_cookie_consent.py
Normal file
70
e2e/test_cookie_consent.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""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()
|
||||
|
||||
# Click the <summary> element to expand <details> inside the banner
|
||||
banner.locator("details summary").click()
|
||||
|
||||
# Analytics checkbox is now revealed; check it and save
|
||||
analytics_checkbox = banner.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
61
e2e/test_feeds.py
Normal 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 "<rss" in content or "<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 "<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 "<rss" in content or "<feed" in content
|
||||
54
e2e/test_home.py
Normal file
54
e2e/test_home.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""E2E tests for the home page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
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(re.compile("No Hype AI"))
|
||||
|
||||
|
||||
@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()
|
||||
65
e2e/test_newsletter.py
Normal file
65
e2e/test_newsletter.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""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)
|
||||
# Disable the browser's native HTML5 email validation so the JS handler
|
||||
# fires and sends the bad value to the server (which returns 400).
|
||||
page.evaluate("document.querySelector('nav form[data-newsletter-form]').setAttribute('novalidate', '')")
|
||||
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)
|
||||
@@ -17,7 +17,7 @@ pytest-benchmark~=4.0.0
|
||||
factory-boy~=3.3.0
|
||||
wagtail-factories~=4.2.0
|
||||
feedparser~=6.0.0
|
||||
playwright~=1.52.0
|
||||
playwright~=1.57.0
|
||||
pytest-playwright~=0.7.0
|
||||
ruff~=0.6.0
|
||||
mypy~=1.11.0
|
||||
|
||||
Reference in New Issue
Block a user