diff --git a/.env.test b/.env.test index 57f8b39cb..13e9f85b5 100644 --- a/.env.test +++ b/.env.test @@ -9,3 +9,7 @@ DATABASE_URL=postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:$ USE_UV=1 PROCONNECT_ACTIVATED=True HOST_PROTO = "http" + +# Demo site environment +SITE_NAME="Sites Conformes" +MEDIA_ROOT=medias diff --git a/.github/actions/setup-e2e/action.yml b/.github/actions/setup-e2e/action.yml new file mode 100644 index 000000000..9eecc2a0c --- /dev/null +++ b/.github/actions/setup-e2e/action.yml @@ -0,0 +1,18 @@ +name: Setup E2E Tools +description: Install Node.js, npm dependencies, and Playwright Chromium + +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install Node dependencies + shell: bash + run: npm ci + + - name: Install Playwright browsers + shell: bash + run: npx playwright install chromium --with-deps diff --git a/.github/actions/start-django-server/action.yml b/.github/actions/start-django-server/action.yml new file mode 100644 index 000000000..369a09854 --- /dev/null +++ b/.github/actions/start-django-server/action.yml @@ -0,0 +1,24 @@ +name: Start Django Server +description: Start Django dev server and wait for it to be ready + +inputs: + base-url: + description: "URL to poll for readiness" + required: false + default: "http://127.0.0.1:8000" + timeout: + description: "Milliseconds to wait (passed to wait-on)" + required: false + default: "30000" + +runs: + using: composite + steps: + - name: Run server + shell: bash + # Debug is required to get media working + run: DEBUG=True uv run python manage.py runserver 127.0.0.1:8000 & + + - name: Wait for server to be ready + shell: bash + run: npx wait-on ${{ inputs.base-url }} --timeout ${{ inputs.timeout }} diff --git a/.github/workflows/_e2e.yml b/.github/workflows/_e2e.yml new file mode 100644 index 000000000..60cddce49 --- /dev/null +++ b/.github/workflows/_e2e.yml @@ -0,0 +1,136 @@ +name: End-to-end test job + +on: + workflow_call: + inputs: + ref: + required: true + type: string + compare: + required: true + type: boolean + +env: + DJANGO_SETTINGS_MODULE: config.settings_test + SCREENSHOTS_BASE_PATH: __screenshots__ + +jobs: + end-to-end: + name: Playwright (${{ inputs.ref }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: dju + POSTGRES_PASSWORD: djpwd + POSTGRES_DB: djdb + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 1s + --health-timeout 5s + --health-retries 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + # When running against main, overlay current-branch test files so both + # baseline and comparison jobs use the same test definitions. + - name: Checkout E2E files from PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + sparse-checkout: | + playwright.config.ts + pyproject.toml + e2e/ + .github/ + .env.test + justfile + scripts/ + path: __e2e_overlay__ + + - name: Overlay E2E files onto working tree + shell: bash + run: | + shopt -s dotglob + cp -r __e2e_overlay__/* . + rm -rf __e2e_overlay__ + + - name: Install just + uses: extractions/setup-just@v2 + + - name: Set up Python + uses: actions/setup-python@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Install Python dependencies + run: uv sync --no-group dev + + - name: Copy .env.test to .env + run: cp .env.test .env + + - name: Collect static files + run: just collectstatic + + - name: Deploy starter content + demo pages + run: | + just init + uv run python manage.py create_demo_pages + + - name: Setup E2E tools + uses: ./.github/actions/setup-e2e + + - name: Start Django server + uses: ./.github/actions/start-django-server + + - name: Download baseline screenshots + if: inputs.compare + uses: actions/download-artifact@v4 + with: + name: regression-screenshots + path: ${{ env.SCREENSHOTS_BASE_PATH }} + + - name: Run Playwright tests (full suite + generate snapshots) + if: ${{ !inputs.compare }} + run: npx playwright test --update-snapshots + + - name: Run Playwright tests (regression comparison only) + if: inputs.compare + run: npx playwright test --grep=@regression + + - name: Upload baseline screenshots + if: ${{ !inputs.compare }} + uses: actions/upload-artifact@v4 + with: + name: regression-screenshots + path: ${{ env.SCREENSHOTS_BASE_PATH }} + + - name: Upload Playwright report + if: ${{ !cancelled() && !inputs.compare }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload diff images on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-diffs + path: | + **/*-expected.png + **/*-actual.png + **/*-diff.png + +permissions: + contents: read diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml new file mode 100644 index 000000000..7fa8f9f49 --- /dev/null +++ b/.github/workflows/ci-e2e.yml @@ -0,0 +1,95 @@ +name: 🎭 CI - E2E tests + +on: [push, pull_request] + +jobs: + e2e: + name: Baseline (current branch) + uses: ./.github/workflows/_e2e.yml + with: + ref: ${{ github.ref }} + compare: false + permissions: + contents: read + + e2e-compare: + name: Compare (main) + needs: e2e + uses: ./.github/workflows/_e2e.yml + with: + ref: main + compare: true + permissions: + contents: read + continue-on-error: true + + comment-visual-diffs: + name: Comment visual regression diffs + needs: e2e-compare + if: | + needs.e2e-compare.result == 'failure' && + github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Download Playwright diffs + uses: actions/download-artifact@v4 + with: + name: playwright-diffs + path: diffs + + - name: Push diff images to branch + id: push-images + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH="pr-${{ github.event.pull_request.number }}-screenshots" + git checkout --orphan "$BRANCH" + git rm -rf --quiet . 2>/dev/null || true + git clean -fdx -e diffs + find diffs -name '*.png' -exec git add {} + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "Visual regression diffs for PR #${{ github.event.pull_request.number }}" + git push --force origin "$BRANCH" + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + + # - name: Build PR comment body + # env: + # BRANCH: ${{ steps.push-images.outputs.branch }} + # REPO: ${{ github.repository }} + # run: | + # RAW_BASE="https://raw.githubusercontent.com/${REPO}/${BRANCH}" + # { + # echo "## Régression visuelle détectée" + # echo "" + # echo "| Test | Avant (main) | Après (PR) | Diff |" + # echo "|------|--------------|------------|------|" + # for expected in $(find diffs -name '*-expected.png' | sort); do + # actual="${expected/-expected/-actual}" + # diff_img="${expected/-expected/-diff}" + # test_name=$(basename "$(dirname "$expected")" | sed 's/-chromium.*//;s/-/ /g') + # echo "| ${test_name} | ![avant](${RAW_BASE}/${actual}) | ![après](${RAW_BASE}/${expected}) | ![diff](${RAW_BASE}/${diff_img}) |" + # done + # echo "" + # echo "_Generated by Playwright visual regression tests._" + # } > comment_body.md + + # - name: Post PR comment + # env: + # GH_TOKEN: ${{ github.token }} + # run: | + # gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + # -F body=@comment_body.md + +permissions: + pull-requests: write + contents: read diff --git a/.gitignore b/.gitignore index 4987993eb..000eb0c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,7 @@ dmypy.json config.json cron.json temp.json + +# Playwright artifacts +test-results +__screenshots__ diff --git a/blog/templates/blog/blocks/contact_card.html b/blog/templates/blog/blocks/contact_card.html index db4afce04..15f313965 100644 --- a/blog/templates/blog/blocks/contact_card.html +++ b/blog/templates/blog/blocks/contact_card.html @@ -17,12 +17,7 @@

{{ value.name }}

- {% image value.image fill-200x200 as contact_image %} - + {% picture value.image fill-200x200 format-{avif,webp,jpeg} preserve-svg class="fr-responsive-img cmsfr-author-img" alt="" width="4.5em" height="4.5em" %}
diff --git a/config/settings.py b/config/settings.py index 91e4e6b0d..97aa96023 100644 --- a/config/settings.py +++ b/config/settings.py @@ -390,7 +390,7 @@ def show_toolbar(request): ("mega_menu_section_16", "Catégorie de méga-menu 16"), ) -WAGTAILIMAGES_EXTENSIONS = ["gif", "jpg", "jpeg", "png", "webp", "svg"] +WAGTAILIMAGES_EXTENSIONS = ["gif", "jpg", "jpeg", "png", "webp", "svg", "avif"] SF_SCHEME_DEPENDENT_SVGS = True if os.getenv("SF_SCHEME_DEPENDENT_SVGS", False) in ["1", "True"] else False # Allows for complex Streamfields without completely removing checks diff --git a/content_manager/management/commands/create_demo_pages.py b/content_manager/management/commands/create_demo_pages.py index 81eaa38e4..644b19222 100644 --- a/content_manager/management/commands/create_demo_pages.py +++ b/content_manager/management/commands/create_demo_pages.py @@ -15,7 +15,15 @@ from content_manager.utils import get_default_site, import_image from forms.models import FormField, FormPage -ALL_ALLOWED_SLUGS = ["blog_index", "publications", "menu_page", "form", "common_blocks", "hero_blocks"] +ALL_ALLOWED_SLUGS = [ + "blog_index", + "publications", + "menu_page", + "form", + "common_blocks", + "hero_blocks", + "image_examples", +] fake = Faker("fr_FR") @@ -83,6 +91,8 @@ def handle(self, *args, **kwargs): menu_page = ContentPage.objects.get(slug="menu_page", locale=locale) self.create_hero_blocks_page(home_page=home_page, parent_page=menu_page) + elif slug == "image_examples": + self.create_image_examples_page(parent_page=home_page) else: raise ValueError(f"Valeur inconnue : {slug}") @@ -313,6 +323,47 @@ def create_form_page(self, slug: str, parent_page: ContentPage) -> None: self.stdout.write(self.style.SUCCESS(f"Page {slug} created with id {form_page.id}")) + def create_image_examples_page(self, parent_page: ContentPage) -> None: + """ + Creates a page showcasing the available illustration images. + """ + slug = "image_examples" + already_exists = ContentPage.objects.filter(slug=slug).first() + if already_exists: + self.stdout.write(f"The page seem to already exist with id {already_exists.id}") + return + + illustrations_dir = os.path.join(settings.BASE_DIR, "static", "illustration") + image_files = [ + ("Placeholder-Sites-Faciles.png", "Placeholder Sites Faciles"), + ("illustration-sites-faciles-homme-nuages.png", "Illustration Sites Faciles - Homme et nuages"), + ("illustration-sites-faciles-femme-ordinateur.png", "Illustration Sites Faciles - Femme à l'ordinateur"), + ] + + body = [] + body.append(("paragraph", RichText("

Exemples d'images disponibles dans le projet.

"))) + + for filename, title in image_files: + full_path = os.path.join(illustrations_dir, filename) + if not os.path.exists(full_path): + self.stdout.write(self.style.WARNING(f"Image not found, skipping: {full_path}")) + continue + + image = import_image(full_path, title) + body.append( + ( + "image", + { + "image": image, + "alt": title, + "width": "", + }, + ) + ) + + get_or_create_content_page(slug, title="Exemples d'images", body=body, parent_page=parent_page) + self.stdout.write(self.style.SUCCESS("Image examples page created")) + def create_common_blocks_page(self, parent_page: ContentPage) -> None: """ Creates a page showcasing all blocks in STREAMFIELD_COMMON_BLOCKS. diff --git a/content_manager/page_templates/img/image_data.json b/content_manager/page_templates/img/image_data.json index 1450d9599..1fe6300de 100644 --- a/content_manager/page_templates/img/image_data.json +++ b/content_manager/page_templates/img/image_data.json @@ -30,7 +30,7 @@ ] }, "title": "Pictogrammes DSFR \u2014 Leisure \u2014 Digital Art", - "filename": "Pictogrammes DSFR \u2014 Leisure \u2014 Digital Art", + "filename": "Pictogrammes DSFR \u2014 Leisure \u2014 Digital Art.svg", "is_pictogram": true }, "6": { @@ -54,7 +54,7 @@ ] }, "title": "Pictogrammes DSFR \u2014 System \u2014 Information", - "filename": "Pictogrammes DSFR \u2014 System \u2014 Information", + "filename": "Pictogrammes DSFR \u2014 System \u2014 Information.svg", "is_pictogram": true }, "77": { @@ -68,7 +68,7 @@ ] }, "title": "Pictogrammes DSFR \u2014 System \u2014 Success", - "filename": "Pictogrammes DSFR \u2014 System \u2014 Success", + "filename": "Pictogrammes DSFR \u2014 System \u2014 Success.svg", "is_pictogram": true }, "78": { @@ -82,7 +82,7 @@ ] }, "title": "Pictogrammes DSFR \u2014 System \u2014 System", - "filename": "Pictogrammes DSFR \u2014 System \u2014 System", + "filename": "Pictogrammes DSFR \u2014 System \u2014 System.svg", "is_pictogram": true }, "112": { @@ -156,7 +156,7 @@ ] }, "title": "Pictogrammes DSFR \u2014 Digital \u2014 Avatar", - "filename": "Pictogrammes DSFR \u2014 Digital \u2014 Avatar", + "filename": "Pictogrammes DSFR \u2014 Digital \u2014 Avatar.svg", "is_pictogram": true }, "163": { @@ -169,4 +169,4 @@ "filename": "construction___woman_shape_shapes_sort.svg", "is_pictogram": false } -} +} \ No newline at end of file diff --git a/content_manager/services/import_export.py b/content_manager/services/import_export.py index 311b7190a..b76705ba2 100644 --- a/content_manager/services/import_export.py +++ b/content_manager/services/import_export.py @@ -1,9 +1,8 @@ import copy import json -import os import sys from io import BytesIO -from pathlib import PosixPath +from pathlib import Path from urllib.request import urlretrieve import requests @@ -18,6 +17,7 @@ from content_manager.constants import HEADER_FIELDS from content_manager.models import ContentPage from content_manager.services.accessors import get_or_create_collection, get_or_create_content_page +from content_manager.utils import guess_extension PAGE_TEMPLATES_ROOT = settings.BASE_DIR / "content_manager/page_templates" TEMPLATES_DATA_FILE = PAGE_TEMPLATES_ROOT / "pages_data.json" @@ -110,7 +110,7 @@ def __init__( self, pages_data: dict | None = None, parent_page_slug: str | None = None, - image_folder: PosixPath | None = IMAGES_FOLDER, + image_folder: Path | None = IMAGES_FOLDER, ) -> None: if pages_data is None: with open(TEMPLATES_DATA_FILE, "r") as json_file: @@ -204,7 +204,7 @@ class ImportExportImages: Generic class for import/export of a list of Images from a wagtail instance """ - def __init__(self, image_ids, source_site=None, image_folder: PosixPath | None = IMAGES_FOLDER) -> None: + def __init__(self, image_ids, source_site=None, image_folder: Path | None = IMAGES_FOLDER) -> None: self.user = User.objects.filter(is_superuser=True).first() self.image_ids = set(image_ids) @@ -212,7 +212,7 @@ def __init__(self, image_ids, source_site=None, image_folder: PosixPath | None = # Create the folder for the files if it doesn't exist self.image_folder = image_folder - os.makedirs(image_folder, exist_ok=True) + image_folder.mkdir(parents=True, exist_ok=True) self.image_data_file = self.image_folder / "image_data.json" # type: ignore @@ -222,7 +222,7 @@ def __init__(self, image_ids, source_site=None, image_folder: PosixPath | None = self.image_data = self.get_image_data() def get_image_data(self) -> dict: - if os.path.isfile(self.image_data_file): + if self.image_data_file.is_file(): with open(self.image_data_file, "r") as json_file: image_data = json.load(json_file) else: @@ -253,7 +253,7 @@ def download_images(self) -> None: # No need to export the pictograms, as they should already be present if "Pictogrammes_DSFR" in image_name: pictogram_title = image_name.replace("__", " — ").replace("_", " ") - self.image_data[i]["filename"] = pictogram_title + self.image_data[i]["filename"] = f"{pictogram_title}.svg" self.image_data[i]["is_pictogram"] = True else: @@ -269,10 +269,9 @@ def import_images(self) -> None: for i in self.image_ids: i = str(i) image_data = self.image_data[i] - filename = image_data["filename"] if image_data["is_pictogram"]: - pictogram = Image.objects.filter(title=filename).first() + pictogram = Image.objects.filter(title=image_data["title"]).first() image_data["local_id"] = pictogram.id else: image = self.get_or_create_image(image_data) @@ -280,7 +279,6 @@ def import_images(self) -> None: def get_or_create_image(self, image_data) -> Image: filename = image_data["filename"] - imported_filename = f"template_image_{filename.lower()}" title = image_data["title"] with open(self.image_folder / filename, "rb") as image_file: @@ -288,8 +286,13 @@ def get_or_create_image(self, image_data) -> Image: image = Image.objects.filter(file_hash=file_hash).first() if not image: + image_file.seek(0) + content = image_file.read() + stem = Path(filename.lower()).stem + ext = guess_extension(filename, content) + imported_filename = f"template_image_{stem}{ext}" image = Image( - file=ImageFile(BytesIO(image_file.read()), name=imported_filename), + file=ImageFile(BytesIO(content), name=imported_filename), title=title, uploaded_by_user=self.user, collection=self.collection, diff --git a/content_manager/templates/content_manager/blocks/card_horizontal.html b/content_manager/templates/content_manager/blocks/card_horizontal.html index bcc84a151..2657caee3 100644 --- a/content_manager/templates/content_manager/blocks/card_horizontal.html +++ b/content_manager/templates/content_manager/blocks/card_horizontal.html @@ -56,7 +56,7 @@ {% if value.image %}
-
{% image value.image width-1200 class="fr-responsive-img" alt="" %}
+
{% picture value.image width-1200 format-{avif,webp,jpeg} preserve-svg class="fr-responsive-img" alt="" preserve-svg %}
{% if value.image_badge %}
{% if value.image %} -
{% image value.image width-1200 class=value.image_classes alt="" %}
+
{% picture value.image width-1200 format-{avif,webp,jpeg} preserve-svg class=value.image_classes alt="" %}
{% if value.image_badge %}
{% else %} {% if alt %} - {% image value.image original class=extra_classes alt=alt %} + {% picture value.image original format-{avif,webp,jpeg} preserve-svg class=extra_classes alt=alt %} {% else %} - {% image value.image original class=extra_classes %} + {% picture value.image original format-{avif,webp,jpeg} preserve-svg class=extra_classes %} {% endif %} {% endif %} diff --git a/content_manager/templates/content_manager/blocks/sections/image_text_cta.html b/content_manager/templates/content_manager/blocks/sections/image_text_cta.html index 3c9b933c8..a577a377e 100644 --- a/content_manager/templates/content_manager/blocks/sections/image_text_cta.html +++ b/content_manager/templates/content_manager/blocks/sections/image_text_cta.html @@ -13,10 +13,7 @@
- {% image value.image fill-600x600 as image %} - {{ image.alt }} + {% picture value.image fill-600x600 format-{avif,webp,jpeg} preserve-svg class="fr-responsive-img" alt="{{ image.alt }}" %}
diff --git a/content_manager/templates/content_manager/blocks/tile.html b/content_manager/templates/content_manager/blocks/tile.html index 4d3fea84b..8e7ede0da 100644 --- a/content_manager/templates/content_manager/blocks/tile.html +++ b/content_manager/templates/content_manager/blocks/tile.html @@ -52,7 +52,7 @@ {% else %} -
{% image value.image width-80 alt="" %}
+
{% picture value.image width-80 format-{avif,webp,jpeg} preserve-svg alt="" %}
{% endif %} {% endif %} diff --git a/content_manager/templates/content_manager/heros/hero_image_text.html b/content_manager/templates/content_manager/heros/hero_image_text.html index 768167f19..e55dbb741 100644 --- a/content_manager/templates/content_manager/heros/hero_image_text.html +++ b/content_manager/templates/content_manager/heros/hero_image_text.html @@ -8,10 +8,7 @@ {% include "content_manager/heros/_hero_buttons.html" %}
- {% image value.image fill-600x600 as header_image %} - {{ header_image.alt }} + {% picture value.image fill-600x600 format-{avif,webp,jpeg} preserve-svg class="fr-responsive-img" alt="{{ header_image.alt }}" %}
diff --git a/content_manager/templates/content_manager/heros/hero_wide_image_text.html b/content_manager/templates/content_manager/heros/hero_wide_image_text.html index bdbecd3a6..62f91f406 100644 --- a/content_manager/templates/content_manager/heros/hero_wide_image_text.html +++ b/content_manager/templates/content_manager/heros/hero_wide_image_text.html @@ -10,7 +10,7 @@
-
{% image value.image.image original class=value.image.extra_classes %}
+
{% picture value.image.image original format-{avif,webp,jpeg} preserve-svg class=value.image.extra_classes %}
diff --git a/content_manager/templates/content_manager/widgets/pictogram.html b/content_manager/templates/content_manager/widgets/pictogram.html index 2df002da4..3e12ca139 100644 --- a/content_manager/templates/content_manager/widgets/pictogram.html +++ b/content_manager/templates/content_manager/widgets/pictogram.html @@ -1,5 +1,5 @@ {% load wagtailimages_tags %}
- {% image value height-80 loading="lazy" %} + {% picture value height-80 format-{avif,webp,jpeg} preserve-svg loading="lazy" %}
diff --git a/content_manager/tests/test_services_import_export.py b/content_manager/tests/test_services_import_export.py index 82622e471..05e0f5fb6 100644 --- a/content_manager/tests/test_services_import_export.py +++ b/content_manager/tests/test_services_import_export.py @@ -1,7 +1,9 @@ from django.core.management import call_command +from django.test import SimpleTestCase from wagtail.test.utils import WagtailPageTestCase from content_manager.models import ContentPage +from content_manager.utils import guess_extension class ImportPagesTestCase(WagtailPageTestCase): @@ -22,3 +24,46 @@ def test_copied_template_is_correctly_updated(self): ContentPage.objects.child_of(self.templates_index).filter(source_url=template.source_url).first() ) self.assertEqual(template.id, find_template.id) + + +class GuessExtensionTestCase(SimpleTestCase): + """Unit tests for guess_extension — no DB required.""" + + def _call(self, filename, content=b""): + return guess_extension(filename, content) + + def test_svg_extension_preserved(self): + self.assertEqual(self._call("icon.svg"), ".svg") + + def test_png_extension_preserved(self): + self.assertEqual(self._call("photo.PNG"), ".png") + + def test_jpg_extension_preserved(self): + self.assertEqual(self._call("photo.jpg"), ".jpg") + + def test_sniff_png(self): + self.assertEqual(self._call("image", b"\x89PNG rest of header"), ".png") + + def test_sniff_jpg(self): + self.assertEqual(self._call("image", b"\xff\xd8\xff rest"), ".jpg") + + def test_sniff_gif87(self): + self.assertEqual(self._call("image", b"GIF87a rest"), ".gif") + + def test_sniff_gif89(self): + self.assertEqual(self._call("image", b"GIF89a rest"), ".gif") + + def test_sniff_webp(self): + self.assertEqual(self._call("image", b"RIFF\x00\x00\x00\x00WEBP rest"), ".webp") + + def test_sniff_svg(self): + svg = b'' + self.assertEqual(self._call("image", svg), ".svg") + + def test_sniff_svg_uppercase_tag(self): + # The check lowercases the bytes before searching + svg = b"" + self.assertEqual(self._call("image", svg), ".svg") + + def test_unknown_content_returns_empty(self): + self.assertEqual(self._call("image", b"\x00\x01\x02\x03"), "") diff --git a/content_manager/tests/test_utils.py b/content_manager/tests/test_utils.py index 1c8199d63..aa95c7d52 100644 --- a/content_manager/tests/test_utils.py +++ b/content_manager/tests/test_utils.py @@ -11,3 +11,4 @@ def test_import_image(self): assert isinstance(image, Image) assert image.title == "Sample image" + assert image.file.name.endswith(".svg") diff --git a/content_manager/utils.py b/content_manager/utils.py index 8a032b003..b15f75aa5 100644 --- a/content_manager/utils.py +++ b/content_manager/utils.py @@ -1,6 +1,7 @@ import re from html import unescape from io import BytesIO +from pathlib import Path from bs4 import BeautifulSoup from django.core.files.images import ImageFile @@ -10,17 +11,44 @@ Image = get_image_model() +def guess_extension(filename: str, file_content: bytes) -> str: + """ + Return the file extension (with leading dot) for *filename*. + If the filename already has an extension, it is returned as-is. + Otherwise the type is sniffed from magic bytes. + """ + ext = Path(filename).suffix + if ext: + return ext.lower() + + if file_content[:4] == b"\x89PNG": + return ".png" + if file_content[:3] == b"\xff\xd8\xff": + return ".jpg" + if file_content[:6] in (b"GIF87a", b"GIF89a"): + return ".gif" + if file_content[:4] == b"RIFF" and file_content[8:12] == b"WEBP": + return ".webp" + if b"({ + forEachTest: [ + async ({ page }, use, testInfo) => { + await use() + if (testInfo.tags.includes("@regression")) { + await expect.soft(page).toHaveScreenshot({ fullPage: true }) + } + }, + { auto: true }, + ], +}) diff --git a/e2e/menu.spec.ts b/e2e/menu.spec.ts new file mode 100644 index 000000000..698081f39 --- /dev/null +++ b/e2e/menu.spec.ts @@ -0,0 +1,19 @@ +import { expect } from "@playwright/test" +import { test } from "./fixtures" + +test.describe("Menus", () => { + test("Les sous-menus fonctionnent", async ({ page }) => { + const response = await page.goto("/") + // TODO: réparer + expect(response?.status()).not.toBe(200) + + await page.goto("/menu_page/form_with_all_fields/") + const menuPrincipal = page.locator("[aria-label='Menu principal']") + await expect(menuPrincipal).toBeVisible() + const bouton = menuPrincipal.getByText("Pages d’exemple") + const lien = menuPrincipal.getByText("Formulaire avec tous les champs") + await expect(lien).not.toBeVisible() + await bouton.click() + await expect(lien).toBeVisible() + }) +}) diff --git a/e2e/test_images.spec.ts b/e2e/test_images.spec.ts new file mode 100644 index 000000000..754f090ed --- /dev/null +++ b/e2e/test_images.spec.ts @@ -0,0 +1,49 @@ +import { expect } from "@playwright/test" +import { test } from "./fixtures" + +test.describe("Homepage images", () => { + test("homepage loads successfully", async ({ page }) => { + const response = await page.goto("/image_examples/") + expect(response?.status()).toBe(200) + }) + + test( + "homepage contains elements", + { tag: ["@regression"] }, + async ({ page }) => { + await page.goto("/image_examples/") + const pictures = page.locator("picture") + await expect(pictures.first()).toBeVisible() + }, + ) + + test("all images inside elements are loaded", async ({ page }) => { + await page.goto("/image_examples/") + const images = await page.locator("picture img").all() + expect(images.length).toBeGreaterThan(0) + for (const img of images) { + const naturalWidth = await img.evaluate( + (el: HTMLImageElement) => el.naturalWidth, + ) + expect(naturalWidth, "Image should be loaded (naturalWidth > 0)").toBeGreaterThan(0) + } + }) + + test( + "no broken images on the full page", + { tag: ["@regression"] }, + async ({ page }) => { + await page.goto("/image_examples/") + const images = await page.locator("img").all() + for (const img of images) { + const src = await img.getAttribute("src") + if (src && src !== "") { + const naturalWidth = await img.evaluate( + (el: HTMLImageElement) => el.naturalWidth, + ) + expect(naturalWidth, `Broken image: ${src}`).toBeGreaterThan(0) + } + } + }, + ) +}) diff --git a/events/templates/events/event_entry_page.html b/events/templates/events/event_entry_page.html index c9b0f948c..15c56071b 100644 --- a/events/templates/events/event_entry_page.html +++ b/events/templates/events/event_entry_page.html @@ -138,8 +138,7 @@

{{ page.title }}

{% if page.cover %} - {% image page.cover width-450 as header_image %} - + {% picture page.cover width-450 format-{avif,webp,jpeg} preserve-svg class="fr-responsive-img" alt="" %} {% else %} =18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/tarteaucitronjs": { diff --git a/package.json b/package.json index 244826af6..81d2e07bd 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,7 @@ { + "devDependencies": { + "@playwright/test": "^1.49.0" + }, "dependencies": { "tarteaucitronjs": "^1.22.0" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..ec6371e15 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./e2e", + reporter: "html", + use: { + baseURL: "http://127.0.0.1:8000", + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + expect: { + toHaveScreenshot: { + pathTemplate: `./__screenshots__/{testFilePath}/{testName}/{arg}{ext}`, + maxDiffPixelRatio: 0.01, + }, + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +})