Files
Anthias/tests/conftest.py
Viktor Petersson 473d8991bf feat(website): home-page screenshot slider fed from CI captures (#2899)
* feat(website): home-page screenshot slider fed from CI captures

- Replace the static overview hero with a scroll-snap slider framed
  as a browser-window chrome (traffic lights, URL pill, counter,
  brand-yellow autoplay progress bars).
- Slides are sourced from website/assets/images/screenshots/ —
  gitignored; deploy-website.yaml downloads the latest successful
  marketing-screenshots artifact at build time, and the new
  bun run screenshots:fetch mirrors that for local dev.
- TypeScript slider in assets/js/slider.ts (deferred, 1.4 KB
  gzipped) handles autoplay, pause-off-screen, hover-pause,
  keyboard nav, and respects prefers-reduced-motion.
- Add a system-info marketing capture; the integration test
  overwrites the rendered DOM with curated Pi-5-shaped values
  under MARKETING_SCREENSHOTS=1 (page_context lives in uvicorn,
  out of monkeypatch reach).
- Flip marketing_screenshot fixture default to full_page=False so
  every capture is uniform 1400×900, fitting the slider's frame.
- Add a GitHub Sponsors link in the hero CTA and footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): adopt Font Awesome icon kit + actionlint SC2012

- Add @fortawesome/fontawesome-free as a devDependency; mirror the
  fonts:install pattern with a new scripts/install-icons.ts that
  materializes a curated set (github, linkedin, x-twitter, heart)
  into assets/images/icons/ (gitignored).
- New partials/icon.html inlines the SVG via safeHTML so
  fill="currentColor" picks up the surrounding text colour — same
  Tailwind class can tint the icon and the label.
- Replace fb / instagram footer links with LinkedIn; swap the
  hand-rolled twitter + GitHub + Sponsor-heart SVGs over to the
  Font Awesome equivalents.
- Fix actionlint SC2012 in deploy-website.yaml by switching the
  post-download `ls | wc -l` to `find -name '*.png' | wc -l`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): add YouTube footer link; soften shellcheck comment

- Extend install-icons.ts list with the FA `youtube` brand SVG and
  drop a fourth social link into the footer pointing at
  https://www.youtube.com/c/screenlydigitalsignage.
- Reword the deploy-website.yaml comment so it no longer starts with
  `# shellcheck`, which actionlint was misreading as an SC1072/SC1073
  directive and failing the lint run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(website): drop X/Twitter from footer social row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): address Copilot review on slider PR

- slider.ts: init activeIndex to -1 so the very first setActive(0)
  actually applies state (the equal-index early return was
  swallowing it; autoplay bar never started).
- baseof.html / index.html: pass a stable "screenshots-singleton"
  cache key to partialCached so the Resize pipeline runs once per
  build, not once per page.
- index.html / main.css / slider.ts: drop the role="tab" inside
  role="tablist" markup (the full ARIA Tabs pattern doesn't fit a
  horizontal-scroll carousel); use plain buttons with aria-current
  on the active pill instead.
- screenshots.html: correct the partial docstring — the layout
  omits the slider region when the slice is empty, it doesn't fall
  back to a placeholder.
- package.json: invoke the locally-pinned tailwindcss binary
  instead of `bunx @tailwindcss/cli`. bunx resolves through its
  own cache and was pulling Tailwind v4.3.0 even though the lock
  pins 4.2.4, so the committed style.css banner drifted.
  Rebuilt style.css against the pinned version (v4.2.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): address second Copilot pass on slider PR

- deploy-website.yaml: add `actions: read` to the workflow's
  permissions block so the Fetch marketing screenshots step can call
  `gh run list` / `gh run download`. An explicit `permissions:` block
  defaults unspecified scopes to `none`, so the Actions API would
  otherwise 403.
- slider.ts: track the URL-pill fade `setTimeout` and clear it on
  every `setActive`. A rapid second slide change (button mash, swipe
  + observer update) could otherwise let a stale timeout fire later
  and briefly overwrite the URL pill with the previous slide's text.
- screenshots.html: capture the 1440-wide PNG/WebP renditions during
  the srcset ladder loop and reuse them for the <img src> + LCP
  preload fallback. Avoids re-calling $src.Resize with the same spec
  the loop already produced. Falls back to the source's own width
  when it's narrower than 1440.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): scope deploy permissions per-job; --repo support for fetch

Addresses the third Copilot pass + SonarCloud's new_security_rating
gate failure (S8264: read permissions declared at workflow level).

- deploy-website.yaml: move permissions to job level. `build` keeps
  `actions: read` (gh run list/download) + `contents: read` (checkout)
  + `pages: write` (configure-pages); `deploy` keeps `pages: write` +
  `id-token: write`. Least privilege per job, no more workflow-level
  scope inheritance for the read tokens.
- scripts/fetch-screenshots.ts: target `Screenly/Anthias` by default
  (passed through `gh run list` / `gh run download` via `--repo`) so
  contributors on fork clones still get the upstream artifact instead
  of failing against their own empty Actions API. `--repo <owner>/
  <repo>` overrides.
- data/screenshots.yaml: header comment no longer references the
  "committed seed copies" — the directory is gitignored and the
  workflow downloads into an empty dir.
- website/README.md: document the upstream-default + --repo flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): read autoplay duration from CSS; correct restart comment

Fourth Copilot pass: addresses the duplicated-constant and stale-comment
flags in slider.ts. The two artifact-path concerns Copilot raised on
deploy-website.yaml and scripts/fetch-screenshots.ts are verified false
positives — `actions/upload-artifact@v4` stores the upload relative to
the `path:` argument, so the artifact's top-level entries already are
the .png files (confirmed by downloading the latest run; no
`test-artifacts/marketing/` prefix present).

- slider.ts: drop the duplicated `AUTOPLAY_MS = 6000` constant. The
  authoritative timing value lives on `--autoplay-ms` in main.css
  (drives the @keyframes width animation on the progress bar). The
  JS now reads that custom property at init via getComputedStyle and
  uses it for the setTimeout cadence, so changing one in CSS no
  longer silently drifts away from the slide-advance timing. Keeps a
  `AUTOPLAY_MS_FALLBACK` for the unlikely case where the property is
  unset / unparseable.
- slider.ts: rewrite the "Force-restart the CSS animation by
  detaching+reattaching the node" comment — that's not what the code
  does. The animation restart is purely a CSS-selector consequence
  of removing `data-state` from the previously-active pill, no DOM
  manipulation involved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(tests): clarify system-info smoke-test docstring

Copilot flagged that the docstring overclaimed coverage. The assertions
only check that the heading renders and no 5xx fires; they don't
validate individual System Info values. Reword so the docstring matches
what the test actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): add role=region to slider carousel root

Many AT only announce aria-roledescription when it augments a
non-generic role. Adding role=region keeps the existing aria-label
as the accessible name and matches the W3C carousel pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): keep slider pill state in sync with off-screen + focus

Two Copilot-flagged drift bugs:

1. The off-screen visibility observer cleared the JS autoplay timer
   but left the active pill at data-state=playing. The CSS progress
   bar kept advancing while the slider was below the fold, so when
   it scrolled back into view the fresh full-duration timer and the
   bar disagreed. Delegate to hoverPause/hoverResume so both pause
   together and restart together.

2. focusout bubbles, so moving keyboard focus between elements
   inside the slider (track → next button) fired the root-level
   focusout and re-armed autoplay mid-nav. Check relatedTarget and
   skip when focus is still within refs.root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): derive og:image:alt from the actual page image

When the screenshots dir is empty the OG image falls back to
logo.svg, but the alt text stayed pinned to the old "dashboard
showing scheduled content" line — wrong for the logo and also
wrong if the first slide changes. Pull the alt from the same
$heroSlides[0] used to choose $pageImage, with "Anthias logo"
as the fallback when there are no slides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): drop unused --frame-bg custom property

Declared on .screenshot-slider but never referenced — the actual
background uses an inline linear-gradient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:53:17 +01:00

458 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Root-level pytest configuration.
This file enables the unit-test suite to run on a developer's host
without Docker, without a Redis server, and without the system PyGObject
stack the viewer service normally requires. Integration tests
(``-m integration``) opt back into real services by replacing these
fixtures themselves.
Three concerns are handled here, in order:
1. Force ``ENVIRONMENT=test`` so ``anthias_server.django_project/settings.py`` selects
the SQLite test-DB branch (a repo-local path under ``BASE_DIR`` by
default; CI overrides via ``ANTHIAS_TEST_DB_PATH``).
2. Stub ``gi`` / ``gi.repository`` / ``pydbus`` in ``sys.modules`` *before*
any application module is imported. ``viewer/__init__.py`` does
``import pydbus`` at module load, and ``pydbus`` in turn imports
``gi.repository.Gio`` — which only resolves on hosts with the
distribution's ``python3-gi`` package installed and wired into the
active interpreter. The stubs let the import succeed; tests that
exercise dbus paths mock the relevant calls themselves.
3. Replace ``anthias_common.utils.connect_to_redis`` with a dict-backed
``MagicMock`` factory before any test module imports it, then expose
the same fake via an autouse fixture. Several modules call
``r = connect_to_redis()`` at import time
(``anthias_server.celery_tasks``, ``anthias_server.lib.github``, ``anthias_server.lib.telemetry``, ...); patching
the factory once at conftest load time means the module-level ``r``
bindings hold a fake, not a client pointed at host ``redis``.
Browser-test concerns specific to the Playwright integration suite —
the shared ``browser_context_args`` / ``browser_type_launch_args``
overrides and the opt-in ``marketing_screenshot`` capture fixture —
live at the bottom of this file, after the unit-test setup.
"""
import importlib.util
import os
import sys
import types
from collections.abc import Callable, Iterator
from io import BytesIO
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
# ---------------------------------------------------------------------------
# 1. ENVIRONMENT=test (settings.py reads this at import time)
# ---------------------------------------------------------------------------
os.environ.setdefault('ENVIRONMENT', 'test')
# ---------------------------------------------------------------------------
# 2. Stub gi / gi.repository / pydbus before viewer modules are loaded
# ---------------------------------------------------------------------------
def _install_dbus_stubs() -> None:
"""
Insert stand-in modules so ``import pydbus`` (and its transitive
``from gi.repository import Gio, GLib, GObject``) succeeds on
hosts that don't have the full PyGObject + pydbus stack.
Three host shapes show up in the wild:
1. Both ``gi`` and ``pydbus`` available (target image): no-op.
2. ``gi`` missing entirely (typical dev laptop without
``python3-gi`` apt package): stub both, because the real
pydbus's module-load does ``from gi.repository import
GLib, GObject`` and our minimal gi stub can't satisfy that.
3. ``gi`` present but ``pydbus`` not pip-installed: stub only
pydbus.
"""
gi_missing = importlib.util.find_spec('gi') is None
pydbus_missing = importlib.util.find_spec('pydbus') is None
if gi_missing:
gi_module = types.ModuleType('gi')
gi_repository = types.ModuleType('gi.repository')
# MagicMock for the GLib/Gio/GObject surface — pydbus only
# touches these when a bus is actually constructed (e.g.
# ``pydbus.SessionBus()`` inside a function body); tests that
# hit those paths mock them themselves.
setattr(gi_repository, 'Gio', MagicMock(name='gi.repository.Gio'))
setattr(gi_repository, 'GLib', MagicMock(name='gi.repository.GLib'))
setattr(
gi_repository, 'GObject', MagicMock(name='gi.repository.GObject')
)
setattr(gi_module, 'repository', gi_repository)
sys.modules['gi'] = gi_module
sys.modules['gi.repository'] = gi_repository
if pydbus_missing or gi_missing:
pydbus_module = types.ModuleType('pydbus')
setattr(
pydbus_module, 'SessionBus', MagicMock(name='pydbus.SessionBus')
)
setattr(pydbus_module, 'SystemBus', MagicMock(name='pydbus.SystemBus'))
sys.modules['pydbus'] = pydbus_module
_install_dbus_stubs()
# ---------------------------------------------------------------------------
# 3. Defensive Redis mock — replace connect_to_redis() everywhere
# ---------------------------------------------------------------------------
def _make_fake_redis() -> MagicMock:
"""
A dict-backed Redis mock matching the surface our code uses.
String ops (get/set/delete/expire/exists/flushdb/publish) and list
ops (rpush/lpop/blpop) are modelled on the real Redis semantics so
test paths that exercise both — notably ``ReplyCollector.recv_json``
via BLPOP — see realistic behaviour rather than no-ops.
"""
store: dict[str, Any] = {}
fake = MagicMock(name='FakeRedis')
fake.get.side_effect = store.get
def _set(
key: str,
value: Any,
*,
nx: bool = False,
ex: int | None = None,
**_: Any,
) -> bool | None:
# Match real Redis ``SET ... NX``: succeeds only if the key is
# not already present, returns True on success / None on no-op.
# Tests for SETNX-based gates (per-asset recheck cooldown,
# sweep singleton lock, splash IP-refresh debounce) rely on
# this — without it, every "is the lock held?" check would
# spuriously believe the lock was free.
if nx and key in store:
return None
store[key] = value
return True
fake.set.side_effect = _set
fake.delete.side_effect = lambda *keys: sum(
1 for k in keys if store.pop(k, None) is not None
)
fake.expire.side_effect = lambda key, _ttl: bool(key in store)
fake.exists.side_effect = lambda *keys: sum(1 for k in keys if k in store)
fake.flushdb.side_effect = lambda: store.clear()
fake.publish.side_effect = lambda channel, msg: 0
def _eval(script: str, numkeys: int, *args: Any) -> Any:
# Compare-and-delete is the only ``EVAL`` script in the
# codebase (sweep lock release in anthias_server.celery_tasks.py). Implement
# that pattern directly; any other script becomes a no-op.
if "redis.call('get', KEYS[1])" in script and "'del'" in script:
keys = list(args[:numkeys])
argv = list(args[numkeys:])
if keys and argv and store.get(keys[0]) == argv[0]:
store.pop(keys[0], None)
return 1
return 0
return None
fake.eval.side_effect = _eval
def _rpush(key: str, *values: Any) -> int:
bucket = store.setdefault(key, [])
bucket.extend(values)
return len(bucket)
def _lpop(key: str) -> Any:
bucket = store.get(key)
if not bucket:
return None
value = bucket.pop(0)
if not bucket:
store.pop(key, None)
return value
def _blpop(keys: Any, timeout: float | None = None) -> Any:
# Real BLPOP blocks until a value is available or the timeout
# expires. Tests don't drive a writer thread, so block-then-
# poll behaviour collapses to "return immediately": yield the
# first available value, or None if every key is empty.
if isinstance(keys, (str, bytes)):
keys = [keys]
for key in keys:
value = _lpop(key)
if value is not None:
return (key, value)
return None
fake.rpush.side_effect = _rpush
fake.lpop.side_effect = _lpop
fake.blpop.side_effect = _blpop
return fake
# Patch ``connect_to_redis`` at the source so the module-level
# ``r = connect_to_redis()`` bindings in anthias_server.celery_tasks / anthias_server.lib.github /
# anthias_server.lib.telemetry / api.views.mixins / viewer / etc. all resolve to the
# fake the moment those modules are first imported.
_SESSION_FAKE_REDIS = _make_fake_redis()
def _patch_connect_to_redis() -> None:
import anthias_common.utils as _lib_utils
_lib_utils.connect_to_redis = lambda: _SESSION_FAKE_REDIS
_patch_connect_to_redis()
@pytest.fixture(scope='session', autouse=True)
def _ensure_assetdir() -> None:
"""
Some legacy fixtures (e.g. ``api/tests/test_v1_endpoints.py::
cleanup_asset_dir``) iterate ``settings['assetdir']`` during
teardown without creating it first. The Docker test image creates
``/data/anthias_assets`` in its build (see
``docker/Dockerfile.test.j2``); local hosts have no such guarantee.
Materialise the path once per session so those fixtures don't
``FileNotFoundError`` out before the test even runs.
"""
from anthias_server.settings import settings as _anthias_settings
asset_dir = _anthias_settings.get('assetdir')
if asset_dir:
os.makedirs(asset_dir, exist_ok=True)
# Browser-test failure artifacts are owned by pytest-playwright. The
# `--tracing retain-on-failure --screenshot only-on-failure
# --output test-artifacts` flags in pyproject.toml's addopts make it
# write `<output>/<test-id>/{trace.zip,test-failed-1.png}` for failed
# tests and nothing for passing ones. The GH Actions
# upload-artifact@v7 step in test-runner.yml uploads the directory on
# job failure, where the trace replays via `playwright show-trace`.
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
"""Set ``DJANGO_ALLOW_ASYNC_UNSAFE=1`` only when the run actually
contains integration tests.
Playwright's sync API spins up an internal asyncio loop to talk
to the browser over the CDP socket; Django's ORM detects that
loop and refuses sync calls (``SynchronousOnlyOperation``) unless
this flag is set. The single-threaded test process never invokes
ORM and Playwright concurrently, so the safety net Django enforces
isn't doing useful work for this suite.
Setting it process-wide unconditionally would also disable the
safety net for unit tests, where an accidental ORM call from
inside an event loop is a real bug we want Django to flag. Hooking
at collection time means a unit-only run (``pytest -m "not
integration"``) leaves the variable unset and the check active;
a run that includes integration tests sets it once before
pytest-django's DB setup (which would otherwise hit the same
check itself).
"""
if any('integration' in item.keywords for item in items):
os.environ['DJANGO_ALLOW_ASYNC_UNSAFE'] = '1'
@pytest.fixture(autouse=True)
def _mock_redis(monkeypatch: pytest.MonkeyPatch) -> Iterator[MagicMock]:
"""
Replace ``anthias_common.utils.connect_to_redis`` with a dict-backed
``MagicMock`` for every test, including any module-level
``r = connect_to_redis()`` bindings that fixtures import indirectly.
Tests that need their own Redis mock (e.g. ``tests/test_telemetry.py``,
``tests/test_messaging.py``) override this by patching ``module.r``
directly — that takes precedence inside the per-test setup chain.
"""
fake = _make_fake_redis()
monkeypatch.setattr('anthias_common.utils.connect_to_redis', lambda: fake)
# Replace already-bound ``r`` attributes on modules that called
# connect_to_redis() at import time. Only modules already in
# sys.modules are touched — others get the fake on first import via
# the conftest-level patch above.
for module_path in (
'anthias_server.app.views',
'anthias_server.api.views.mixins',
'anthias_server.api.views.v2',
'anthias_server.celery_tasks',
'anthias_server.lib.github',
'anthias_server.lib.telemetry',
'anthias_viewer',
):
mod = sys.modules.get(module_path)
if mod is not None and hasattr(mod, 'r'):
monkeypatch.setattr(f'{module_path}.r', fake)
yield fake
# ---------------------------------------------------------------------------
# 4. Playwright browser overrides + opt-in marketing capture
# ---------------------------------------------------------------------------
#
# Consolidates the ``browser_context_args`` / ``browser_type_launch_args``
# overrides that test_app.py and test_migrate_to_screenly.py previously
# duplicated. The viewport (1400×900) matches the existing
# website/assets/images/overview*.png convention exactly, so captures
# slot into the Hugo image-set without rescaling.
#
# The ``marketing_screenshot`` fixture below is opt-in via
# MARKETING_SCREENSHOTS=1: when enabled it bumps the context to 3×
# device scale and saves a ``<name>.png`` plus retina ``<name>@2x.png``
# and ``<name>@3x.png`` siblings under test-artifacts/marketing/ —
# matching the website's existing overview*.png naming. Default
# integration runs stay at 1× and the fixture is a no-op so call sites
# in test bodies don't need to branch.
_MARKETING_ENABLED = os.environ.get('MARKETING_SCREENSHOTS') == '1'
_MARKETING_SCALE = 3
_MARKETING_OUT_DIR = Path('test-artifacts/marketing')
@pytest.fixture(scope='session', autouse=True)
def _reset_marketing_dir() -> None:
"""Clear ``test-artifacts/marketing/`` once per session when
marketing capture is enabled. Runs inside the test container
(where pytest executes), so the cleanup has the same root
permissions as the container that originally wrote the files —
a host-side ``rm -rf`` from a CI runner step would hit
permission-denied on a retry attempt because the bind-mounted
artefacts are owned by root.
No-op when MARKETING_SCREENSHOTS is unset; ordinary integration
runs leave any existing marketing/ tree alone."""
if _MARKETING_ENABLED and _MARKETING_OUT_DIR.exists():
import shutil
shutil.rmtree(_MARKETING_OUT_DIR)
@pytest.fixture(scope='session')
def browser_context_args(
browser_context_args: dict[str, Any],
) -> dict[str, Any]:
args = {
**browser_context_args,
'viewport': {'width': 1400, 'height': 900},
}
if _MARKETING_ENABLED:
# Driving the whole session at 3× costs ~25% more CPU per
# test (framebuffer scales with DPR²) — the price of being
# able to drop call sites into existing tests rather than
# fork a parallel marketing suite.
args['device_scale_factor'] = _MARKETING_SCALE
return args
@pytest.fixture(scope='session')
def browser_type_launch_args(
browser_type_launch_args: dict[str, Any],
) -> dict[str, Any]:
# ``--no-sandbox`` because the test container runs as root;
# Chromium's setuid sandbox refuses to come up in that
# configuration and the user-namespace sandbox would need extra
# capabilities at compose-up time.
return {
**browser_type_launch_args,
'args': [*browser_type_launch_args.get('args', []), '--no-sandbox'],
}
MarketingShotFn = Callable[..., None]
@pytest.fixture
def marketing_screenshot(request: pytest.FixtureRequest) -> MarketingShotFn:
"""Capture ``page`` at the context's scale factor and emit a
``<name>.png`` plus retina ``<name>@2x.png`` and ``<name>@3x.png``
siblings under ``test-artifacts/marketing/``. Filename convention
matches the existing website ``overview.png`` / ``overview@2x.png``
/ ``overview@3x.png`` set so Hugo's image-set picker resolves the
new files without additional config — the base 1× is the canonical
URL and the ``@Nx`` siblings are retina sources.
Call as ``marketing_screenshot('home')`` for a viewport-only
capture (default) at 1400×900 — the size the website's home-page
slider expects every slide to be. Pass ``full_page=True`` to
capture the entire scrolled document instead; useful for asset
pages that aren't sliced into the slider.
Viewport-only is the default for two reasons. First, the home-
page slider in ``website/`` lays every slide into a uniform
1400×900 frame, and full-page captures (variable height, growing
with document content) crop unpredictably in that frame. Second,
any capture that includes a ``position: fixed`` overlay needs
viewport-only: Playwright's full-page mode artificially extends
the viewport height to fit the document, and fixed-position
elements anchored to the (now much taller) viewport drift out of
frame or get hidden under the dimming backdrop.
No-op (still callable) when ``MARKETING_SCREENSHOTS`` is unset, so
test bodies can sprinkle calls unconditionally — non-marketing
integration runs pay zero capture cost beyond the function call.
The 3× capture is the source of truth; 2× and 1× variants come
from a LANCZOS downscale via Pillow — same algorithm professional
design tooling uses for retina export. Re-rendering at lower DPRs
produces visible moiré on the asset-table grid lines, so we don't
do that.
"""
if not _MARKETING_ENABLED:
def _noop(name: str, *, full_page: bool = False) -> None:
pass
return _noop
# Only resolve ``page`` when capture is actually active — this
# fixture lives in the root conftest and the ``page`` fixture only
# exists when pytest-playwright is collected, which is only the
# case for integration tests. Lazy fixture lookup keeps unit-test
# collection from importing playwright when MARKETING_SCREENSHOTS
# is unset (the common case).
page = request.getfixturevalue('page')
# Pillow ships with the server image (HEIC normalisation pipeline),
# so the test container has it. Import is local so the non-marketing
# path stays clean if Pillow ever moves out of the server group.
from PIL import Image as _PILImage
_MARKETING_OUT_DIR.mkdir(parents=True, exist_ok=True)
def _capture(name: str, *, full_page: bool = False) -> None:
png_bytes = page.screenshot(full_page=full_page)
# Write the 3× original verbatim — re-encoding through PIL
# would needlessly re-compress.
(_MARKETING_OUT_DIR / f'{name}@3x.png').write_bytes(png_bytes)
src = _PILImage.open(BytesIO(png_bytes))
w, h = src.size
for scale, suffix in ((2, '@2x'), (1, '')):
target = (
int(w * scale / _MARKETING_SCALE),
int(h * scale / _MARKETING_SCALE),
)
resized = src.resize(target, _PILImage.Resampling.LANCZOS)
resized.save(_MARKETING_OUT_DIR / f'{name}{suffix}.png')
return _capture