|
28 | 28 | import json |
29 | 29 | import os |
30 | 30 | import re |
31 | | -import shutil |
32 | 31 | import subprocess |
33 | 32 | import sys |
34 | 33 | import warnings |
35 | 34 | from collections import defaultdict |
36 | 35 | from contextlib import contextmanager, suppress |
37 | 36 | from pathlib import Path |
38 | | -from typing import TYPE_CHECKING, Any, Callable |
| 37 | +from typing import TYPE_CHECKING |
39 | 38 |
|
40 | 39 | import django.conf |
41 | 40 | import pytest |
|
77 | 76 | ] |
78 | 77 |
|
79 | 78 |
|
80 | | -def pytest_addoption(parser: pytest.Parser) -> None: |
81 | | - """Add custom command line options for e2e tests.""" |
82 | | - parser.addoption( |
83 | | - "--visual-snapshots", |
84 | | - action="store_true", |
85 | | - default=False, |
86 | | - help="Enable visual snapshot testing (disabled by default, never runs on CI)", |
87 | | - ) |
88 | | - parser.addoption( |
89 | | - "--update-snapshots", |
90 | | - action="store_true", |
91 | | - default=False, |
92 | | - help="Update visual snapshots instead of comparing (for use with --visual-snapshots)", |
93 | | - ) |
94 | | - |
95 | | - |
96 | 79 | def pytest_configure(config: pytest.Config) -> None: |
97 | 80 | """Build frontend assets before test collection when running e2e tests. |
98 | 81 |
|
@@ -149,15 +132,6 @@ def _color_text(text: str, color_code: int) -> str: |
149 | 132 | print(_color_text("✅ Frontend assets are up to date", GREEN), flush=True) |
150 | 133 |
|
151 | 134 |
|
152 | | -def _visual_snapshots_enabled(config: pytest.Config) -> bool: |
153 | | - """Check if visual snapshots are enabled via CLI flag or environment variable.""" |
154 | | - return config.getoption("--visual-snapshots", False) or os.environ.get("VISUAL_SNAPSHOTS", "").lower() in ( |
155 | | - "1", |
156 | | - "true", |
157 | | - "yes", |
158 | | - ) |
159 | | - |
160 | | - |
161 | 135 | def _frontend_assets_need_rebuild(manifest_file) -> bool: |
162 | 136 | """Check if frontend assets need to be rebuilt.""" |
163 | 137 | if not manifest_file.is_file(): |
@@ -286,186 +260,3 @@ def auth_page(auth_context: BrowserContext) -> Iterator[Page]: |
286 | 260 | with console_errors_raised(page): |
287 | 261 | yield page |
288 | 262 | page.close() |
289 | | - |
290 | | - |
291 | | -# Add a data store for computed paths |
292 | | -class SnapshotPaths: |
293 | | - snapshots_path: Path | None = None |
294 | | - failures_path: Path | None = None |
295 | | - |
296 | | - |
297 | | -@pytest.fixture(scope="session", autouse=True) |
298 | | -def _cleanup_snapshot_failures(pytestconfig: pytest.Config): |
299 | | - """ |
300 | | - Clean up snapshot failures directory once at the beginning of test session. |
301 | | -
|
302 | | - The snapshot storage path is relative to each test folder, modeling after the React snapshot locations |
303 | | - """ |
304 | | - |
305 | | - root_dir = Path(pytestconfig.rootdir) |
306 | | - |
307 | | - # Compute paths once |
308 | | - SnapshotPaths.snapshots_path = root_dir / "__snapshots__" |
309 | | - |
310 | | - SnapshotPaths.failures_path = root_dir / "snapshot_failures" |
311 | | - |
312 | | - # Clean up the entire failures directory at session start so past failures don't clutter the result |
313 | | - if SnapshotPaths.failures_path.exists(): |
314 | | - shutil.rmtree(SnapshotPaths.failures_path, ignore_errors=True) |
315 | | - |
316 | | - # Create the directory to ensure it exists |
317 | | - with suppress(FileExistsError): |
318 | | - SnapshotPaths.failures_path.mkdir(parents=True, exist_ok=True) |
319 | | - |
320 | | - yield |
321 | | - |
322 | | - |
323 | | -@pytest.fixture |
324 | | -def assert_snapshot(pytestconfig: pytest.Config, request: pytest.FixtureRequest) -> Callable: |
325 | | - if not _visual_snapshots_enabled(pytestconfig): |
326 | | - |
327 | | - def noop(*args: Any, **kwargs: Any) -> None: |
328 | | - pass |
329 | | - |
330 | | - noop.NOOP = True # type: ignore[attr-defined]s |
331 | | - return noop |
332 | | - |
333 | | - from io import BytesIO |
334 | | - |
335 | | - import pytest |
336 | | - from PIL import Image |
337 | | - from pixelmatch.contrib.PIL import pixelmatch |
338 | | - |
339 | | - test_function_name = request.node.name |
340 | | - SNAPSHOT_MESSAGE_PREFIX = "[playwright-visual-snapshot]" |
341 | | - |
342 | | - test_name_without_params = test_function_name.split("[", 1)[0] |
343 | | - test_name = f"{test_function_name}[{sys.platform!s}]" |
344 | | - |
345 | | - current_test_file_path = Path(request.node.fspath) |
346 | | - current_test_file_path.parent.resolve() |
347 | | - |
348 | | - # Use global paths if available, otherwise calculate per test |
349 | | - snapshots_path = SnapshotPaths.snapshots_path |
350 | | - assert snapshots_path |
351 | | - |
352 | | - snapshot_failures_path = SnapshotPaths.failures_path |
353 | | - assert snapshot_failures_path |
354 | | - |
355 | | - # we know this exists because of the default value on ini |
356 | | - global_snapshot_threshold = 0.1 |
357 | | - |
358 | | - mask_selectors = [] |
359 | | - update_snapshot = pytestconfig.getoption("--update-snapshots", False) |
360 | | - |
361 | | - # for automatically naming multiple assertions |
362 | | - counter = 0 |
363 | | - # Collection to store failures |
364 | | - failures = [] |
365 | | - |
366 | | - def _create_locators_from_selectors(page: Page, selectors: list[str]): |
367 | | - """ |
368 | | - Convert a list of CSS selector strings to locator objects |
369 | | - """ |
370 | | - return [page.locator(selector) for selector in selectors] |
371 | | - |
372 | | - def compare( |
373 | | - img_or_page: bytes | Any, |
374 | | - *, |
375 | | - threshold: float | None = None, |
376 | | - name=None, |
377 | | - fail_fast=False, |
378 | | - mask_elements: list[str] | None = None, |
379 | | - ) -> None: |
380 | | - nonlocal counter |
381 | | - |
382 | | - if not name: |
383 | | - if counter > 0: |
384 | | - name = f"{test_name}_{counter}.png" |
385 | | - else: |
386 | | - name = f"{test_name}.png" |
387 | | - |
388 | | - # Use global threshold if no local threshold provided |
389 | | - if not threshold: |
390 | | - threshold = global_snapshot_threshold |
391 | | - |
392 | | - # If page reference is passed, use screenshot |
393 | | - if isinstance(img_or_page, Page): |
394 | | - # Combine configured mask elements with any provided in the function call |
395 | | - all_mask_selectors = list(mask_selectors) |
396 | | - if mask_elements: |
397 | | - all_mask_selectors.extend(mask_elements) |
398 | | - |
399 | | - # Convert selectors to locators |
400 | | - masks = _create_locators_from_selectors(img_or_page, all_mask_selectors) if all_mask_selectors else [] |
401 | | - |
402 | | - img = img_or_page.screenshot( |
403 | | - animations="disabled", |
404 | | - type="png", |
405 | | - mask=masks, |
406 | | - # TODO only for jpeg |
407 | | - # quality=100, |
408 | | - ) |
409 | | - else: |
410 | | - img = img_or_page |
411 | | - |
412 | | - # test file without the extension |
413 | | - test_file_name_without_extension = current_test_file_path.stem |
414 | | - |
415 | | - # Created a nested folder to store screenshots: snapshot/test_file_name/test_name/ |
416 | | - test_file_snapshot_dir = snapshots_path / test_file_name_without_extension / test_name_without_params |
417 | | - test_file_snapshot_dir.mkdir(parents=True, exist_ok=True) |
418 | | - |
419 | | - screenshot_file = test_file_snapshot_dir / name |
420 | | - |
421 | | - # Create a dir where all snapshot test failures will go |
422 | | - # ex: snapshot_failures/test_file_name/test_name |
423 | | - failure_results_dir = snapshot_failures_path / test_file_name_without_extension / test_name |
424 | | - |
425 | | - # increment counter before any failures are recorded |
426 | | - counter += 1 |
427 | | - |
428 | | - if update_snapshot: |
429 | | - screenshot_file.write_bytes(img) |
430 | | - failures.append(f"{SNAPSHOT_MESSAGE_PREFIX} Snapshots updated. Please review images. {screenshot_file}") |
431 | | - return |
432 | | - |
433 | | - if not screenshot_file.exists(): |
434 | | - screenshot_file.write_bytes(img) |
435 | | - failures.append( |
436 | | - f"{SNAPSHOT_MESSAGE_PREFIX} New snapshot(s) created. Please review images. {screenshot_file}" |
437 | | - ) |
438 | | - return |
439 | | - |
440 | | - img_a = Image.open(BytesIO(img)) |
441 | | - img_b = Image.open(screenshot_file) |
442 | | - img_diff = Image.new("RGBA", img_a.size) |
443 | | - mismatch = pixelmatch(img_a, img_b, img_diff, threshold=threshold, fail_fast=fail_fast) |
444 | | - |
445 | | - if mismatch == 0: |
446 | | - return |
447 | | - |
448 | | - # Create new test_results folder |
449 | | - failure_results_dir.mkdir(parents=True, exist_ok=True) |
450 | | - img_diff.save(f"{failure_results_dir}/diff_{name}") |
451 | | - img_a.save(f"{failure_results_dir}/actual_{name}") |
452 | | - img_b.save(f"{failure_results_dir}/expected_{name}") |
453 | | - |
454 | | - # on ci, update the existing screenshots in place so we can download them |
455 | | - if os.getenv("CI"): |
456 | | - screenshot_file.write_bytes(img) |
457 | | - |
458 | | - # Still honor fail_fast if specifically requested |
459 | | - if fail_fast: |
460 | | - pytest.fail(f"{SNAPSHOT_MESSAGE_PREFIX} Snapshots DO NOT match! {name}") |
461 | | - |
462 | | - failures.append(f"{SNAPSHOT_MESSAGE_PREFIX} Snapshots DO NOT match! {name}") |
463 | | - |
464 | | - # Register finalizer to report all failures at the end of the test |
465 | | - def finalize(): |
466 | | - if failures: |
467 | | - pytest.fail("\n".join(failures)) |
468 | | - |
469 | | - request.addfinalizer(finalize) |
470 | | - |
471 | | - return compare |
0 commit comments