feat: comprehensive Playwright E2E test suite #7
@@ -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,70 @@ 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 \
|
||||||
|
-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:
|
nightly-e2e:
|
||||||
if: github.event_name == 'schedule'
|
if: github.event_name == 'schedule'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -94,8 +158,14 @@ jobs:
|
|||||||
- name: Start dev server with seeded content
|
- name: Start dev server with seeded content
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name nightly-e2e --network container:nightly-postgres \
|
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 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 \
|
||||||
|
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
||||||
"$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 +176,10 @@ 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 -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: |
|
||||||
|
|||||||
@@ -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,71 @@ 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)
|
||||||
|
# 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(
|
site, _ = Site.objects.get_or_create(
|
||||||
hostname="127.0.0.1",
|
hostname="127.0.0.1",
|
||||||
port=8000,
|
port=8000,
|
||||||
@@ -59,5 +129,7 @@ class Command(BaseCommand):
|
|||||||
site.is_default_site = True
|
site.is_default_site = True
|
||||||
site.site_name = "No Hype AI"
|
site.site_name = "No Hype AI"
|
||||||
site.save()
|
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 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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
python manage.py runserver 0.0.0.0:8000"
|
python manage.py runserver 0.0.0.0:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro
|
||||||
ports:
|
ports:
|
||||||
- "8035:8000"
|
- "8035:8000"
|
||||||
environment:
|
environment:
|
||||||
@@ -21,6 +22,7 @@ services:
|
|||||||
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
||||||
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
||||||
NEWSLETTER_PROVIDER: buttondown
|
NEWSLETTER_PROVIDER: buttondown
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-tools/browsers
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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
|
factory-boy~=3.3.0
|
||||||
wagtail-factories~=4.2.0
|
wagtail-factories~=4.2.0
|
||||||
feedparser~=6.0.0
|
feedparser~=6.0.0
|
||||||
playwright~=1.52.0
|
playwright~=1.57.0
|
||||||
pytest-playwright~=0.7.0
|
pytest-playwright~=0.7.0
|
||||||
ruff~=0.6.0
|
ruff~=0.6.0
|
||||||
mypy~=1.11.0
|
mypy~=1.11.0
|
||||||
|
|||||||
Reference in New Issue
Block a user