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} |  |  |  |" + # 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 @@
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 %}