diff --git a/components/ecoindex/best_practices/default_rules.yaml b/components/ecoindex/best_practices/default_rules.yaml index 7031c12..d41c173 100644 --- a/components/ecoindex/best_practices/default_rules.yaml +++ b/components/ecoindex/best_practices/default_rules.yaml @@ -62,3 +62,134 @@ rules: higher_is_worse: false thresholds: fail: 1 + + - id: stylesheets_number + enabled: true + checker: stylesheets_number + category: network + rweb_id: RWEB_0035 + url: https://rweb.greenit.fr/en/fiches/RWEB_0035-limit-the-number-of-css + title: "Limit the number of CSS" + description: > + Limit stylesheet files to avoid multiplying HTTP requests and simplify + browser rendering. Prefer a shared CSS plus a few media-specific sheets. + thresholds: + # GreenIT-Analysis: >7 warn, >10 fail + warn: 7 + fail: 10 + + - id: standard_typefaces + enabled: true + checker: standard_typefaces + category: network + rweb_id: RWEB_0032 + url: https://rweb.greenit.fr/en/fiches/RWEB_0032-prefer-standard-fonts + title: "Prefer standard fonts" + description: > + Prefer system fonts already available on the user device. Downloaded + web fonts consume bandwidth and delay text rendering. + thresholds: + warn: 1 + fail: 2 + + - id: social_network_button + enabled: true + checker: social_network_button + category: user-device + rweb_id: RWEB_0059 + url: https://rweb.greenit.fr/en/fiches/RWEB_0059-replace-the-official-social-network-sharing-buttons + title: "Replace official social network sharing buttons" + description: > + Official social widgets load heavy third-party scripts. Prefer lightweight + share links that do not pull external SDKs. + thresholds: + fail: 0 + + - id: animated_gifs + enabled: true + checker: animated_gifs + category: user-device + rweb_id: RWEB_0099 + url: https://rweb.greenit.fr/en/fiches/RWEB_0099-limit-the-use-of-animated-gif + title: "Limit the use of animated GIFs" + description: > + Animated GIFs are heavier and less efficient than modern video formats + such as WebM or MP4 for animations. + thresholds: + fail: 0 + + - id: http_errors + enabled: true + checker: http_errors + category: network + title: "Avoid HTTP error responses" + description: > + Resources returning HTTP status codes greater than or equal to 400 waste + bandwidth and often indicate broken dependencies. + thresholds: + fail: 0 + + - id: http_redirects + enabled: true + checker: http_redirects + category: network + rweb_id: RWEB_0112 + url: https://rweb.greenit.fr/en/fiches/RWEB_0112-avoir-redirections + title: "Avoid redirects" + description: > + Redirects degrade response time and consume resources. Prefer direct URLs + whenever possible. + thresholds: + fail: 1 + + - id: http2 + enabled: true + checker: http2 + category: network + rweb_id: RWEB_0083 + url: https://rweb.greenit.fr/en/fiches/RWEB_0083-http2-over-http1 + title: "Prefer HTTP/2 to HTTP/1" + description: > + HTTP/2 multiplexes resources over a single connection and transfers fewer + bytes than HTTP/1, improving load time and efficiency. + thresholds: + fail: 0 + + - id: cache_headers + enabled: true + checker: cache_headers + category: network + rweb_id: RWEB_0075 + url: https://rweb.greenit.fr/en/fiches/RWEB_0075-add-expires-or-cache-control-headers + title: "Add Expires or Cache-Control headers" + description: > + Static resources should declare cache lifetime via Cache-Control or + Expires so browsers avoid re-downloading unchanged assets. + thresholds: + fail: 0 + + - id: http_compression + enabled: true + checker: http_compression + category: network + rweb_id: RWEB_0076 + url: https://rweb.greenit.fr/en/fiches/RWEB_0076-compressing-text-files-css-js-html-and-svg + title: "Compress CSS, JavaScript, HTML and SVG files" + description: > + Text resources should be served with HTTP compression (gzip, brotli, …) + to reduce transferred bytes. + thresholds: + fail: 0 + + - id: no_cookie_static + enabled: true + checker: no_cookie_static + category: datacenter + rweb_id: RWEB_0081 + url: https://rweb.greenit.fr/en/fiches/RWEB_0081-host-static-resources-on-a-cookie-free-domain + title: "Host static resources on a cookie-free domain" + description: > + Cookies attached to static asset requests inflate every transfer. Serve + CSS, JS, images and fonts from a cookie-less domain. + thresholds: + fail: 1 diff --git a/components/ecoindex/best_practices/har_utils.py b/components/ecoindex/best_practices/har_utils.py new file mode 100644 index 0000000..8fc3555 --- /dev/null +++ b/components/ecoindex/best_practices/har_utils.py @@ -0,0 +1,159 @@ +"""Helpers inspired by GreenIT-Analysis HAR resource classification.""" + +from __future__ import annotations + +import re +from datetime import datetime +from urllib.parse import urlparse + +from ecoindex.models.scraper import RequestItem + +HTTP_REDIRECT_CODES = frozenset({301, 302, 303, 307}) +HTTP_COMPRESSION_TOKENS = frozenset( + {"br", "compress", "deflate", "gzip", "pack200-gzip"} +) + +_STATIC_CATEGORIES = frozenset( + {"image", "javascript", "font", "css", "audio", "video"} +) +_COMPRESSIBLE_CATEGORIES = frozenset({"css", "javascript", "html", "font"}) + +_FONT_MIME_RE = re.compile( + r"(font/|application/(?:x-)?font-|application/vnd\.ms-fontobject|" + r"application/font-sfnt)", + re.I, +) +_COMPRESSIBLE_MIME_RE = re.compile( + r"^(text/|application/(?:javascript|x-javascript|json|xml|xhtml\+xml|" + r"ld\+json|rss\+xml|atom\+xml|manifest\+json)|image/svg\+xml|" + r"font/(?:eot|opentype))", + re.I, +) +_STATIC_MIME_RE = re.compile( + r"^(image/|audio/|video/|text/css|text/javascript|application/javascript|" + r"application/x-javascript|font/|application/(?:x-)?font-|" + r"application/vnd\.ms-fontobject|application/pdf|application/octet-stream|" + r"application/zip|application/x-shockwave-flash|application/manifest\+json)", + re.I, +) + +_SOCIAL_PATTERNS: list[tuple[str, str]] = [ + ("platform.twitter.com/widgets.js", "twitter"), + ("platform.linkedin.com/in.js", "linkedin"), + ("assets.pinterest.com/js/pinit.js", "pinterest"), + ("platform-api.sharethis.com/js/sharethis.js", "sharethis"), + ("s7.addthis.com/js/300/addthis_widget.js", "addthis"), + ("static.addtoany.com/menu/page.js", "addtoany"), +] + + +def header_map(headers: list[dict] | None) -> dict[str, str]: + result: dict[str, str] = {} + if not headers: + return result + for header in headers: + name = header.get("name") + value = header.get("value") + if name is None or value is None: + continue + result[str(name).lower()] = str(value) + return result + + +def is_font_resource(item: RequestItem) -> bool: + if item.category == "font" or _FONT_MIME_RE.search(item.mime_type or ""): + return item.size > 0 + mime = (item.mime_type or "").split(";")[0].strip().lower() + if mime in {"", "text/plain", "application/octet-stream"}: + path = urlparse(item.url).path.lower() + if path.endswith((".woff", ".woff2", ".ttf", ".otf", ".eot")): + return item.size > 0 + if ".woff?" in item.url.lower() or ".woff2?" in item.url.lower(): + return item.size > 0 + return False + + +def is_css_resource(item: RequestItem) -> bool: + return item.category == "css" or "css" in (item.mime_type or "").lower() + + +def is_gif_resource(item: RequestItem) -> bool: + mime = (item.mime_type or "").lower() + if "gif" in mime: + return True + path = urlparse(item.url).path.lower() + return path.endswith(".gif") + + +def is_static_resource(item: RequestItem) -> bool: + if item.category in _STATIC_CATEGORIES: + return True + return bool(_STATIC_MIME_RE.search((item.mime_type or "").split(";")[0].strip())) + + +def is_compressible_resource(item: RequestItem) -> bool: + if item.size <= 150: + return False + if item.category in _COMPRESSIBLE_CATEGORIES: + return True + return bool( + _COMPRESSIBLE_MIME_RE.search((item.mime_type or "").split(";")[0].strip()) + ) + + +def is_resource_compressed(item: RequestItem) -> bool: + encoding = (item.content_encoding or "").lower().strip() + if not encoding: + return False + # Content-Encoding can be a comma-separated list. + tokens = {token.strip() for token in encoding.split(",")} + return bool(tokens & HTTP_COMPRESSION_TOKENS) + + +def has_valid_cache_headers(item: RequestItem) -> bool: + cache_control = item.cache_control or "" + is_valid = False + if cache_control and not re.search( + r"(no-cache)|(no-store)|(max-age\s*=\s*0)", cache_control, re.I + ): + is_valid = True + + if item.expires: + expires = _parse_http_date(item.expires) + if expires is not None: + now = _parse_http_date(item.response_date) or datetime.now(tz=expires.tzinfo) + if expires < now: + is_valid = False + + return is_valid + + +def _parse_http_date(value: str | None) -> datetime | None: + if not value: + return None + try: + from email.utils import parsedate_to_datetime + + return parsedate_to_datetime(value) + except (TypeError, ValueError, IndexError): + return None + + +def is_http1(item: RequestItem) -> bool: + version = (item.http_version or "").upper() + return "HTTP/1" in version + + +def is_http_redirect(status: int) -> bool: + return status in HTTP_REDIRECT_CODES + + +def official_social_network(url: str) -> str | None: + lower = url.lower() + host = (urlparse(url).hostname or "").lower() + if host == "connect.facebook.net" and "sdk.js" in lower: + return "facebook" + for pattern, name in _SOCIAL_PATTERNS: + if pattern in lower: + return name + return None diff --git a/components/ecoindex/best_practices/models.py b/components/ecoindex/best_practices/models.py index b9ead77..dad6e94 100644 --- a/components/ecoindex/best_practices/models.py +++ b/components/ecoindex/best_practices/models.py @@ -14,6 +14,7 @@ class RuleCategory(str, Enum): network = "network" user_device = "user-device" + datacenter = "datacenter" class Thresholds(BaseModel): diff --git a/components/ecoindex/best_practices/rules/network.py b/components/ecoindex/best_practices/rules/network.py index 8d32dbf..395f972 100644 --- a/components/ecoindex/best_practices/rules/network.py +++ b/components/ecoindex/best_practices/rules/network.py @@ -1,4 +1,16 @@ from ecoindex.best_practices.context import BestPracticesContext +from ecoindex.best_practices.har_utils import ( + has_valid_cache_headers, + is_compressible_resource, + is_css_resource, + is_font_resource, + is_gif_resource, + is_http1, + is_http_redirect, + is_resource_compressed, + is_static_resource, + official_social_network, +) from ecoindex.best_practices.models import CheckOutcome, RuleDefinition from ecoindex.best_practices.registry import register @@ -24,3 +36,177 @@ def check_domains_number( details=domains, message=f"{len(domains)} distinct domain(s)", ) + + +@register("stylesheets_number") +def check_stylesheets_number( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + urls: list[str] = [] + seen: set[str] = set() + for item in context.requests.items: + if not is_css_resource(item): + continue + if item.url in seen: + continue + seen.add(item.url) + urls.append(item.url) + return CheckOutcome( + value=float(len(urls)), + details=urls, + message=f"{len(urls)} stylesheet(s)", + ) + + +@register("standard_typefaces") +def check_standard_typefaces( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + fonts = [item.url for item in context.requests.items if is_font_resource(item)] + return CheckOutcome( + value=float(len(fonts)), + details=fonts, + message=f"{len(fonts)} downloaded font(s)", + ) + + +@register("social_network_button") +def check_social_network_button( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + networks: list[str] = [] + seen: set[str] = set() + for item in context.requests.items: + name = official_social_network(item.url) + if not name or name in seen: + continue + seen.add(name) + networks.append(name) + return CheckOutcome( + value=float(len(networks)), + details=networks, + message=( + f"{len(networks)} official social network button(s)" + if networks + else "No official social network button detected" + ), + ) + + +@register("animated_gifs") +def check_animated_gifs( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + gifs = [item.url for item in context.requests.items if is_gif_resource(item)] + return CheckOutcome( + value=float(len(gifs)), + details=gifs, + message=f"{len(gifs)} GIF file(s)", + ) + + +@register("http_errors") +def check_http_errors( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + errors = [ + f"{item.status} {item.url}" + for item in context.requests.items + if item.status >= 400 + ] + return CheckOutcome( + value=float(len(errors)), + details=errors, + message=f"{len(errors)} HTTP error response(s)", + ) + + +@register("http_redirects") +def check_http_redirects( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + redirects = [ + f"{item.status} {item.url}" + for item in context.requests.items + if is_http_redirect(item.status) + ] + return CheckOutcome( + value=float(len(redirects)), + details=redirects, + message=f"{len(redirects)} HTTP redirect(s)", + ) + + +@register("http2") +def check_http2( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + http1 = [ + f"{item.http_version or 'unknown'} {item.url}" + for item in context.requests.items + if is_http1(item) + ] + total = len(context.requests.items) + return CheckOutcome( + value=float(len(http1)), + details=http1, + message=f"{len(http1)}/{total} request(s) using HTTP/1", + ) + + +@register("cache_headers") +def check_cache_headers( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + static_items = [item for item in context.requests.items if is_static_resource(item)] + without_cache = [ + item.url for item in static_items if not has_valid_cache_headers(item) + ] + with_cache = len(static_items) - len(without_cache) + return CheckOutcome( + value=float(len(without_cache)), + details=without_cache, + message=( + f"{with_cache}/{len(static_items)} static resource(s) with cache headers" + ), + ) + + +@register("http_compression") +def check_http_compression( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + uncompressed = [ + item.url + for item in context.requests.items + if is_compressible_resource(item) and not is_resource_compressed(item) + ] + return CheckOutcome( + value=float(len(uncompressed)), + details=uncompressed, + message=f"{len(uncompressed)} compressible resource(s) without compression", + ) + + +@register("no_cookie_static") +def check_no_cookie_static( + context: BestPracticesContext, rule: RuleDefinition +) -> CheckOutcome: + domains: list[str] = [] + seen: set[str] = set() + for item in context.requests.items: + if not is_static_resource(item) or item.cookie_header_size <= 0: + continue + if item.domain in seen: + continue + seen.add(item.domain) + domains.append(item.domain) + return CheckOutcome( + value=float(len(domains)), + details=domains, + message=( + f"{len(domains)} domain(s) serving static resources with cookies" + if domains + else "No cookie sent with static resources" + ), + ) diff --git a/components/ecoindex/database/models/__init__.py b/components/ecoindex/database/models/__init__.py index a49c93f..3593abf 100644 --- a/components/ecoindex/database/models/__init__.py +++ b/components/ecoindex/database/models/__init__.py @@ -116,7 +116,7 @@ class ApiEcoindexBestPractice(SQLModel, table=True): category: str = SQLField( default=..., title="Category", - description="RWEB tier / category (network, user-device, ...)", + description="RWEB tier / category (network, user-device, datacenter, ...)", ) title: str = SQLField( default=..., diff --git a/components/ecoindex/models/scraper.py b/components/ecoindex/models/scraper.py index 72d5e23..043243a 100644 --- a/components/ecoindex/models/scraper.py +++ b/components/ecoindex/models/scraper.py @@ -22,6 +22,12 @@ class RequestItem(BaseModel): size: float status: int url: str + http_version: str | None = None + content_encoding: str | None = None + cache_control: str | None = None + expires: str | None = None + response_date: str | None = None + cookie_header_size: int = 0 class RequestDetail(BaseModel): diff --git a/components/ecoindex/scraper/scrap.py b/components/ecoindex/scraper/scrap.py index 786f97d..b6e0931 100644 --- a/components/ecoindex/scraper/scrap.py +++ b/components/ecoindex/scraper/scrap.py @@ -233,6 +233,19 @@ async def get_requests_from_har_file(self): domain_aggregation[domain].total_size += size self.all_requests.total_count += 1 self.all_requests.total_size += size + + request_headers = { + h["name"].lower(): h["value"] + for h in entry.get("request", {}).get("headers", []) + if "name" in h and "value" in h + } + response_headers = { + h["name"].lower(): h["value"] + for h in entry.get("response", {}).get("headers", []) + if "name" in h and "value" in h + } + cookie_header = request_headers.get("cookie", "") + self.all_requests.items.append( RequestItem( url=url, @@ -241,6 +254,12 @@ async def get_requests_from_har_file(self): status=entry["response"]["status"], size=size, category=category, + http_version=entry.get("response", {}).get("httpVersion"), + content_encoding=response_headers.get("content-encoding"), + cache_control=response_headers.get("cache-control"), + expires=response_headers.get("expires"), + response_date=response_headers.get("date"), + cookie_header_size=len(cookie_header), ) ) self.all_requests.aggregation = MimetypeAggregation(**aggregation) diff --git a/pyproject.toml b/pyproject.toml index f8e7821..da9d2dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,7 @@ extraPaths = ["bases", "components"] "bases/ecoindex/backend" = "ecoindex/backend" "bases/ecoindex/worker" = "ecoindex/worker" "bases/ecoindex/cli" = "ecoindex/cli" +"components/ecoindex/best_practices" = "ecoindex/best_practices" "components/ecoindex/scraper" = "ecoindex/scraper" "components/ecoindex/screenshot_storage" = "ecoindex/screenshot_storage" "components/ecoindex/models" = "ecoindex/models" diff --git a/test/components/ecoindex/best_practices/test_engine.py b/test/components/ecoindex/best_practices/test_engine.py index 5a950c7..4e8dc0d 100644 --- a/test/components/ecoindex/best_practices/test_engine.py +++ b/test/components/ecoindex/best_practices/test_engine.py @@ -27,6 +27,12 @@ def _request( category: str = "other", mime_type: str = "application/octet-stream", size: float = 100, + http_version: str | None = "HTTP/2.0", + content_encoding: str | None = None, + cache_control: str | None = None, + expires: str | None = None, + response_date: str | None = None, + cookie_header_size: int = 0, ) -> RequestItem: return RequestItem( url=url, @@ -35,20 +41,40 @@ def _request( status=status, size=size, category=category, + http_version=http_version, + content_encoding=content_encoding, + cache_control=cache_control, + expires=expires, + response_date=response_date, + cookie_header_size=cookie_header_size, ) +def _rule(checker: str, **kwargs) -> RuleDefinition: + defaults = { + "id": checker, + "checker": checker, + "category": RuleCategory.network, + "title": checker, + "thresholds": Thresholds(), + } + defaults.update(kwargs) + return RuleDefinition(**defaults) + + def test_default_rules_config_loads() -> None: config = load_rules_config() assert config.version == 1 - assert len(config.rules) == 4 + assert len(config.rules) == 14 assert {rule.checker for rule in config.rules} <= set(list_checkers()) assert {rule.category for rule in config.rules} == { RuleCategory.network, RuleCategory.user_device, + RuleCategory.datacenter, } - assert all(rule.rweb_id and rule.rweb_id.startswith("RWEB_") for rule in config.rules) - assert all(rule.url for rule in config.rules) + assert all( + rule.rweb_id is None or rule.rweb_id.startswith("RWEB_") for rule in config.rules + ) assert all(rule.description for rule in config.rules) by_id = {rule.id: rule for rule in config.rules} @@ -57,6 +83,17 @@ def test_default_rules_config_loads() -> None: assert by_id["domains_number"].thresholds.fail == 5 assert by_id["externalize_css_js"].thresholds.fail == 2 assert by_id["print_stylesheet"].higher_is_worse is False + assert by_id["stylesheets_number"].thresholds.fail == 10 + assert by_id["standard_typefaces"].thresholds.fail == 2 + assert by_id["social_network_button"].thresholds.fail == 0 + assert by_id["animated_gifs"].thresholds.fail == 0 + assert by_id["http_errors"].thresholds.fail == 0 + assert by_id["http_redirects"].thresholds.fail == 1 + assert by_id["http2"].thresholds.fail == 0 + assert by_id["cache_headers"].thresholds.fail == 0 + assert by_id["http_compression"].thresholds.fail == 0 + assert by_id["no_cookie_static"].thresholds.fail == 1 + assert by_id["no_cookie_static"].category == RuleCategory.datacenter def test_evaluate_status_higher_is_worse_uses_rweb_max_semantics() -> None: @@ -89,29 +126,125 @@ def test_network_checkers() -> None: }, ) context = BestPracticesContext(requests=requests) - rule = RuleDefinition( - id="x", - checker="http_requests", - category=RuleCategory.network, - title="t", - thresholds=Thresholds(), - ) + rule = _rule("http_requests") assert get_checker("http_requests")(context, rule).value == 3 assert get_checker("domains_number")(context, rule).value == 3 +def test_wave1_and_wave2_checkers() -> None: + requests = Requests( + total_count=10, + items=[ + _request( + "https://cdn.example/a.css", + category="css", + mime_type="text/css", + size=1000, + cache_control="max-age=3600", + content_encoding="gzip", + ), + _request( + "https://cdn.example/b.css", + category="css", + mime_type="text/css", + size=1000, + cache_control="no-cache", + ), + _request( + "https://cdn.example/font.woff2", + category="font", + mime_type="font/woff2", + size=20000, + cookie_header_size=40, + ), + _request( + "https://cdn.example/plain.txt", + category="other", + mime_type="text/plain", + size=10, + http_version="HTTP/1.1", + ), + _request( + "https://connect.facebook.net/en_US/sdk.js", + category="javascript", + mime_type="application/javascript", + size=5000, + ), + _request( + "https://cdn.example/anim.gif", + category="image", + mime_type="image/gif", + size=8000, + ), + _request("https://cdn.example/missing.js", status=404, category="javascript"), + _request("https://cdn.example/old", status=301, category="other"), + _request( + "https://static.example/app.js", + category="javascript", + mime_type="application/javascript", + size=4000, + content_encoding="br", + cache_control="public, max-age=86400", + ), + _request( + "https://static.example/logo.png", + category="image", + mime_type="image/png", + size=3000, + cookie_header_size=12, + ), + ], + ) + context = BestPracticesContext(requests=requests) + + styles = get_checker("stylesheets_number")(context, _rule("stylesheets_number")) + assert styles.value == 2 + assert len(styles.details) == 2 + + fonts = get_checker("standard_typefaces")(context, _rule("standard_typefaces")) + assert fonts.value == 1 + + social = get_checker("social_network_button")( + context, _rule("social_network_button", category=RuleCategory.user_device) + ) + assert social.value == 1 + assert social.details == ["facebook"] + + gifs = get_checker("animated_gifs")( + context, _rule("animated_gifs", category=RuleCategory.user_device) + ) + assert gifs.value == 1 + + errors = get_checker("http_errors")(context, _rule("http_errors")) + assert errors.value == 1 + + redirects = get_checker("http_redirects")(context, _rule("http_redirects")) + assert redirects.value == 1 + + http1 = get_checker("http2")(context, _rule("http2")) + assert http1.value == 1 + + cache = get_checker("cache_headers")(context, _rule("cache_headers")) + assert cache.value >= 1 + assert any(url.endswith("b.css") for url in cache.details) + + compress = get_checker("http_compression")(context, _rule("http_compression")) + assert compress.value >= 1 + assert any(url.endswith("b.css") for url in compress.details) + + cookies = get_checker("no_cookie_static")( + context, _rule("no_cookie_static", category=RuleCategory.datacenter) + ) + assert cookies.value == 2 + assert set(cookies.details) == {"cdn.example", "static.example"} + + def test_dom_checkers() -> None: context = BestPracticesContext( dom=DomMetrics(inline_js=2, inline_css=1, print_stylesheet=0) ) - rule = RuleDefinition( - id="x", - checker="externalize_css_js", - category=RuleCategory.network, - title="t", - thresholds=Thresholds(), - ) + rule = _rule("externalize_css_js") assert get_checker("externalize_css_js")(context, rule).value == 3 assert get_checker("print_stylesheet")(context, rule).value == 0 @@ -201,3 +334,27 @@ def test_engine_loads_custom_yaml(tmp_path: Path) -> None: def test_unknown_checker_raises() -> None: with pytest.raises(KeyError, match="Unknown best-practice checker"): get_checker("does_not_exist") + + +def test_default_engine_runs_all_wave_rules() -> None: + report = BestPracticesEngine().run( + BestPracticesContext( + requests=Requests( + total_count=1, + items=[ + _request( + "https://example.com/", + category="html", + mime_type="text/html", + size=500, + content_encoding="gzip", + cache_control="max-age=60", + ) + ], + domain_aggregation={"example.com": DomainMetrics(total_count=1)}, + ), + dom=DomMetrics(print_stylesheet=1), + ) + ) + assert len(report.results) == 14 + assert report.fail_count == 0 diff --git a/test/components/ecoindex/scraper/test_scraper.py b/test/components/ecoindex/scraper/test_scraper.py index 1ef5684..4efbfda 100644 --- a/test/components/ecoindex/scraper/test_scraper.py +++ b/test/components/ecoindex/scraper/test_scraper.py @@ -212,19 +212,30 @@ async def test_get_requests_from_har_file_includes_domain() -> None: "log": { "entries": [ { - "request": {"url": "https://www.ecoindex.fr/"}, + "request": { + "url": "https://www.ecoindex.fr/", + "headers": [], + }, "response": { "status": 200, + "httpVersion": "HTTP/2.0", + "headers": [ + {"name": "content-encoding", "value": "gzip"}, + {"name": "cache-control", "value": "max-age=3600"}, + ], "content": {"mimeType": "text/html"}, "_transferSize": 1000, }, }, { "request": { - "url": "https://cdn.ecoindex.fr/css/bundle.css" + "url": "https://cdn.ecoindex.fr/css/bundle.css", + "headers": [{"name": "Cookie", "value": "a=1"}], }, "response": { "status": 200, + "httpVersion": "HTTP/1.1", + "headers": [], "content": {"mimeType": "text/css"}, "_transferSize": 500, }, @@ -241,7 +252,12 @@ async def test_get_requests_from_har_file_includes_domain() -> None: requests = await scraper.get_all_requests() assert requests[0].domain == "www.ecoindex.fr" + assert requests[0].http_version == "HTTP/2.0" + assert requests[0].content_encoding == "gzip" + assert requests[0].cache_control == "max-age=3600" assert requests[1].domain == "cdn.ecoindex.fr" + assert requests[1].http_version == "HTTP/1.1" + assert requests[1].cookie_header_size == 3 requests_by_domain = await scraper.get_requests_by_domain() assert requests_by_domain["www.ecoindex.fr"].total_count == 1