diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 360e702..decde0b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" mypy apps config - name: Pytest - run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" pytest + run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" pytest --ignore=e2e - name: Tailwind build (assert generated diff is clean) run: | @@ -67,6 +67,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: | diff --git a/apps/core/management/commands/seed_e2e_content.py b/apps/core/management/commands/seed_e2e_content.py index 82d1f06..37112b6 100644 --- a/apps/core/management/commands/seed_e2e_content.py +++ b/apps/core/management/commands/seed_e2e_content.py @@ -1,21 +1,25 @@ from __future__ import annotations from django.core.management.base import BaseCommand +from taggit.models import Tag from wagtail.models import Page, Site from apps.authors.models import Author -from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage +from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata +from apps.legal.models import LegalIndexPage, LegalPage class Command(BaseCommand): - help = "Seed deterministic content for nightly Playwright E2E checks." + help = "Seed deterministic content for E2E checks." def handle(self, *args, **options): + import datetime + root = Page.get_first_root_node() home = HomePage.objects.child_of(root).first() if home is None: - home = HomePage(title="Nightly Home", slug="nightly-home") + home = HomePage(title="No Hype AI", slug="nohype-home") root.add_child(instance=home) home.save_revision().publish() @@ -33,6 +37,7 @@ class Command(BaseCommand): }, ) + # Primary article — comments enabled, used by nightly journey test article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first() if article is None: article = ArticlePage( @@ -46,6 +51,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", "
This article is tagged with AI Tools.
")], + comments_enabled=True, + ) + article_index.add_child(instance=tagged_article) + tagged_article.save_revision().publish() + tagged_article.tags.add(tag) + tagged_article.save() + + # Third article — comments disabled + no_comments_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-no-comments").first() + if no_comments_article is None: + no_comments_article = ArticlePage( + title="No Comments Article", + slug="e2e-no-comments", + author=author, + summary="An article with comments disabled.", + body=[("rich_text", "Comments are disabled on this one.
")], + comments_enabled=False, + ) + article_index.add_child(instance=no_comments_article) + # 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="We benchmark, so you don't have to.
", + ) + home.add_child(instance=about) + about.save_revision().publish() + + # Legal pages + legal_index = LegalIndexPage.objects.child_of(home).filter(slug="legal").first() + if legal_index is None: + legal_index = LegalIndexPage(title="Legal", slug="legal") + home.add_child(instance=legal_index) + legal_index.save_revision().publish() + + if not LegalPage.objects.child_of(legal_index).filter(slug="privacy-policy").exists(): + privacy = LegalPage( + title="Privacy Policy", + slug="privacy-policy", + body="We take your privacy seriously.
", + last_updated=datetime.date.today(), + show_in_footer=True, + ) + legal_index.add_child(instance=privacy) + privacy.save_revision().publish() + site, _ = Site.objects.get_or_create( hostname="127.0.0.1", port=8000, @@ -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.")) diff --git a/apps/core/tests/test_commands.py b/apps/core/tests/test_commands.py index b7fedb8..381064c 100644 --- a/apps/core/tests/test_commands.py +++ b/apps/core/tests/test_commands.py @@ -2,7 +2,7 @@ import pytest from django.core.management import call_command from django.core.management.base import CommandError -from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage from apps.blog.tests.factories import AuthorFactory @@ -28,3 +28,29 @@ def test_check_content_integrity_fails_for_blank_summary(home_page): with pytest.raises(CommandError, match="empty summary"): call_command("check_content_integrity") + + +@pytest.mark.django_db +def test_seed_e2e_content_creates_expected_pages(): + call_command("seed_e2e_content") + + assert ArticlePage.objects.filter(slug="nightly-playwright-journey").exists() + assert ArticlePage.objects.filter(slug="e2e-tagged-article").exists() + assert ArticlePage.objects.filter(slug="e2e-no-comments").exists() + assert AboutPage.objects.filter(slug="about").exists() + + # Tagged article must carry the seeded tag + tagged = ArticlePage.objects.get(slug="e2e-tagged-article") + assert tagged.tags.filter(slug="ai-tools").exists() + + # No-comments article must have comments disabled + no_comments = ArticlePage.objects.get(slug="e2e-no-comments") + assert no_comments.comments_enabled is False + + +@pytest.mark.django_db +def test_seed_e2e_content_is_idempotent(): + """Running the command twice must not raise or create duplicates.""" + call_command("seed_e2e_content") + call_command("seed_e2e_content") + assert ArticlePage.objects.filter(slug="nightly-playwright-journey").count() == 1 diff --git a/docker-compose.yml b/docker-compose.yml index 4355132..6a2ef70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/e2e/__init__.py b/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/e2e/conftest.py b/e2e/conftest.py new file mode 100644 index 0000000..736cf98 --- /dev/null +++ b/e2e/conftest.py @@ -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() diff --git a/e2e/test_article_detail.py b/e2e/test_article_detail.py new file mode 100644 index 0000000..37b173f --- /dev/null +++ b/e2e/test_article_detail.py @@ -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") diff --git a/e2e/test_articles.py b/e2e/test_articles.py new file mode 100644 index 0000000..71fb854 --- /dev/null +++ b/e2e/test_articles.py @@ -0,0 +1,59 @@ +"""E2E tests for the article index page.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.e2e +def test_article_index_loads(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/", wait_until="networkidle") + expect(page.get_by_role("heading", level=1)).to_be_visible() + # At least one article card must be present after seeding + expect(page.locator("main article").first).to_be_visible() + + +@pytest.mark.e2e +def test_tag_filter_shows_tagged_articles(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/", wait_until="networkidle") + # The seeded "AI Tools" tag link must be present + tag_link = page.get_by_role("link", name="AI Tools") + expect(tag_link).to_be_visible() + tag_link.click() + page.wait_for_load_state("networkidle") + + # URL should now contain ?tag=ai-tools + assert "tag=ai-tools" in page.url + + # The tagged article must appear; no-tag articles may be absent + expect(page.get_by_text("Tagged Article")).to_be_visible() + + +@pytest.mark.e2e +def test_all_tag_clears_filter(page: Page, base_url: str) -> None: + # Start with the tag filter applied + page.goto(f"{base_url}/articles/?tag=ai-tools", wait_until="networkidle") + + # Clicking "All" should return to unfiltered list + page.get_by_role("link", name="All").click() + page.wait_for_load_state("networkidle") + assert "tag=" not in page.url + # All seeded articles should now be visible + expect(page.locator("main article").first).to_be_visible() + + +@pytest.mark.e2e +def test_article_card_navigates_to_detail(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/articles/", wait_until="networkidle") + first_link = page.locator("main article a").first + expect(first_link).to_be_visible() + + href = first_link.get_attribute("href") + assert href, "Article card must have an href" + + first_link.click() + page.wait_for_load_state("networkidle") + + # We should be on an article detail page + expect(page.get_by_role("heading", level=1)).to_be_visible() diff --git a/e2e/test_comments.py b/e2e/test_comments.py new file mode 100644 index 0000000..c104588 --- /dev/null +++ b/e2e/test_comments.py @@ -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) diff --git a/e2e/test_cookie_consent.py b/e2e/test_cookie_consent.py new file mode 100644 index 0000000..0b5d248 --- /dev/null +++ b/e2e/test_cookie_consent.py @@ -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