diff --git a/shared/bundle_analysis/__init__.py b/shared/bundle_analysis/__init__.py index b48ffb73..5d03540b 100644 --- a/shared/bundle_analysis/__init__.py +++ b/shared/bundle_analysis/__init__.py @@ -7,6 +7,7 @@ MissingBaseReportError, MissingBundleError, MissingHeadReportError, + RouteChange, ) from shared.bundle_analysis.parser import Parser from shared.bundle_analysis.report import ( @@ -33,4 +34,5 @@ "ModuleReport", "BundleAnalysisReportLoader", "StoragePaths", + "RouteChange", ] diff --git a/shared/bundle_analysis/comparison.py b/shared/bundle_analysis/comparison.py index 7adceeef..3d0616fa 100644 --- a/shared/bundle_analysis/comparison.py +++ b/shared/bundle_analysis/comparison.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from enum import Enum from functools import cached_property -from typing import Iterator, List, MutableSet, Optional, Tuple +from typing import Dict, Iterator, List, MutableSet, Optional, Tuple import sentry_sdk @@ -12,6 +12,7 @@ AssetReport, BundleAnalysisReport, BundleReport, + BundleRouteReport, ) from shared.bundle_analysis.storage import BundleAnalysisReportLoader from shared.django_apps.core.models import Repository @@ -33,9 +34,9 @@ class MissingBundleError(Exception): @dataclass(frozen=True) -class BundleChange: +class BaseChange: """ - Info about how a bundle has changed between two different reports. + Base class for representing changes between two different reports. """ class ChangeType(Enum): @@ -43,26 +44,37 @@ class ChangeType(Enum): REMOVED = "removed" CHANGED = "changed" - bundle_name: str change_type: ChangeType size_delta: int + + +@dataclass(frozen=True) +class BundleChange(BaseChange): + """ + Info about how a bundle has changed between two different reports. + """ + + bundle_name: str percentage_delta: float @dataclass(frozen=True) -class AssetChange: +class RouteChange(BaseChange): """ - Info about how an asset has changed between two different reports. + Info about how a bundle route has changed between two different reports. """ - class ChangeType(Enum): - ADDED = "added" - REMOVED = "removed" - CHANGED = "changed" + route_name: str + percentage_delta: float + + +@dataclass(frozen=True) +class AssetChange(BaseChange): + """ + Info about how an asset has changed between two different reports. + """ asset_name: str - change_type: ChangeType - size_delta: int AssetMatch = Tuple[Optional[AssetReport], Optional[AssetReport]] @@ -176,6 +188,68 @@ def _match_assets( return matches +class BundleRoutesComparison: + """ + Compares all routes of two bundle route reports for a given bundle + """ + + def __init__( + self, + base_report: BundleRouteReport, + head_report: BundleRouteReport, + ): + self.base_report = base_report + self.head_report = head_report + + @sentry_sdk.trace + def size_changes(self) -> List[RouteChange]: + """ + Returns a list of changes for each unique route that exists between the base and head. + If a route exists on base but not head that is considered "removed" and -100% percentage delta + If a route exists on head but not base that is considered "added" and +100% percentage delta + Otherwise it is considered "changed" and percentage delta = (diff_size / base_size) * 100 + """ + base_sizes = self.base_report.get_sizes() + head_sizes = self.head_report.get_sizes() + + all_routes, results = base_sizes.keys() | head_sizes.keys(), [] + for route_name in all_routes: + # Added new route + if route_name not in base_sizes or base_sizes[route_name] == 0: + results.append( + RouteChange( + route_name=route_name, + change_type=RouteChange.ChangeType.ADDED, + size_delta=head_sizes[route_name], + percentage_delta=100, + ) + ) + # Removed old route + elif route_name not in head_sizes: + results.append( + RouteChange( + route_name=route_name, + change_type=RouteChange.ChangeType.REMOVED, + size_delta=-base_sizes[route_name], + percentage_delta=-100.0, + ) + ) + # Changed + else: + size_delta = head_sizes[route_name] - base_sizes[route_name] + percentage_delta = round((size_delta / base_sizes[route_name]) * 100, 2) + results.append( + RouteChange( + route_name=route_name, + change_type=RouteChange.ChangeType.CHANGED, + size_delta=size_delta, + percentage_delta=percentage_delta, + ) + ) + + return results + + class BundleAnalysisComparison: """ Compares two different bundle analysis reports. @@ -310,3 +384,47 @@ def bundle_comparison(self, bundle_name: str) -> BundleComparison: if base_bundle_report is None or head_bundle_report is None: raise MissingBundleError() return BundleComparison(base_bundle_report, head_bundle_report) + + @sentry_sdk.trace + def bundle_routes_changes(self) -> Dict[str, List[RouteChange]]: + """ + Comparison for all the routes available to a pair of bundles. + """ + comparison_mapping = {} + base_bundle_reports = { + bundle_report.name: bundle_report.full_route_report() + for bundle_report in self.base_report.bundle_reports() + } + head_bundle_reports = { + bundle_report.name: bundle_report.full_route_report() + for bundle_report in self.head_report.bundle_reports() + } + + # Combine all bundle route reports with base and head. If either don't exist + # then it will be set as None in the comparison param. + bundle_names = base_bundle_reports.keys() | head_bundle_reports.keys() + comparison_mapping = { + name: BundleRoutesComparison( + base_bundle_reports.get(name), head_bundle_reports.get(name) + ).size_changes() + for name in bundle_names + } + + return comparison_mapping + + @sentry_sdk.trace + def bundle_routes_changes_by_bundle(self, bundle_name: str) -> List[RouteChange]: + """ + Comparison for all the routes available to a pair of bundles. + """ + base_bundle_report = self.base_report.bundle_report(bundle_name) + head_bundle_report = self.head_report.bundle_report(bundle_name) + if base_bundle_report is None or head_bundle_report is None: + raise MissingBundleError() + + base_route_report = base_bundle_report.full_route_report() + head_route_report = head_bundle_report.full_route_report() + + return BundleRoutesComparison( + base_route_report, head_route_report + ).size_changes() diff --git a/shared/bundle_analysis/parsers/v3.py b/shared/bundle_analysis/parsers/v3.py index efe4ad4a..c17e3fac 100644 --- a/shared/bundle_analysis/parsers/v3.py +++ b/shared/bundle_analysis/parsers/v3.py @@ -3,7 +3,7 @@ import re import uuid from collections import defaultdict -from typing import List, Tuple +from typing import Dict, List, Tuple import ijson import sentry_sdk @@ -99,7 +99,7 @@ def reset(self): self.module_list = [] # dynamic imports: mapping between Chunk and each file name of its dynamic imports - self.dynamic_imports_mapping = defaultdict( + self.dynamic_import_file_names_by_chunk = defaultdict( list ) # typing: Dict[Chunk, List[str]] @@ -284,7 +284,7 @@ def _parse_chunks_event(self, prefix: str, event: str, value: str): elif prefix == "chunks.item.files.item": self.chunk_asset_names.append(value) elif prefix == "chunks.item.dynamicImports.item": - self.dynamic_imports_mapping[self.chunk].append(value) + self.dynamic_import_file_names_by_chunk[self.chunk].append(value) elif (prefix, event) == ("chunks.item", "end_map"): self.chunk_list.append(self.chunk) @@ -322,9 +322,17 @@ def _parse_modules_event(self, prefix: str, event: str, value: str): self.module = None self.module_chunk_unique_external_ids = [] - def _parse_dynamic_imports(self) -> List[dict]: + def _parse_dynamic_imports(self) -> List[Dict[str, int]]: + """ + Computes all the dynamic imports that needs to be inserted to the DB + Returns a list of dictionary objects representing the insert params + [{ + "chunk_id": chunk.id, + "asset_id": asset.id, + }] + """ dynamic_imports_list = [] - for chunk, filenames in self.dynamic_imports_mapping.items(): + for chunk, filenames in self.dynamic_import_file_names_by_chunk.items(): imported_assets = {} for filename in filenames: try: diff --git a/shared/bundle_analysis/report.py b/shared/bundle_analysis/report.py index 877085a7..09bc4906 100644 --- a/shared/bundle_analysis/report.py +++ b/shared/bundle_analysis/report.py @@ -3,12 +3,14 @@ import os import sqlite3 import tempfile +from collections import defaultdict, deque from typing import Any, Dict, Iterator, List, Optional, Set, Tuple import sentry_sdk from sqlalchemy import asc, desc, text from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session as DbSession +from sqlalchemy.orm import aliased from sqlalchemy.orm.query import Query from sqlalchemy.sql import func from sqlalchemy.sql.functions import coalesce @@ -21,6 +23,7 @@ AssetType, Bundle, Chunk, + DynamicImport, Metadata, MetadataKey, Module, @@ -114,6 +117,57 @@ def routes(self) -> Optional[List[str]]: return list(routes) + def dynamically_imported_assets(self) -> List["AssetReport"]: + """ + Returns all dynamically imported assets of the current Asset. + This is retrieving by querying all unique Assets in the DynamicImport + model for each Chunk of the current Asset. + """ + with get_db_session(self.db_path) as session: + # Reattach self.asset to the current session to avoid DetachedInstanceError + asset = session.merge(self.asset) + + # Alias the chunks table for the Asset.chunks relationship + asset_chunks = aliased(Chunk) + + assets = ( + session.query(Asset) + .distinct() + .join(DynamicImport, DynamicImport.asset_id == Asset.id) + .join(Chunk, DynamicImport.chunk_id == Chunk.id) + .join(asset_chunks, asset_chunks.id == DynamicImport.chunk_id) + .filter(asset_chunks.id.in_([chunk.id for chunk in asset.chunks])) + ) + + return ( + AssetReport(self.db_path, asset, self.bundle_info) + for asset in assets.all() + ) + + +class BundleRouteReport: + """ + Report wrapper for asset route analytics. Mainly used for BundleRouteComparison + Stores a dictionary + keys: all routes of the bundle + values: a list of distinct Assets (as AssetReports) that is associated with the route + """ + + def __init__(self, db_path: str, data: Dict[str, List[AssetReport]]): + self.db_path = db_path + self.data = data + + def get_sizes(self) -> Dict[str, int]: + results = {} + for route, asset_reports in self.data.items(): + results[route] = sum([asset.size for asset in asset_reports]) + return results + + def get_size(self, route: str) -> Optional[int]: + if route not in self.data: + return None + return sum([asset.size for asset in self.data[route]]) + class BundleReport: """ @@ -243,6 +297,45 @@ def is_cached(self) -> bool: result = session.query(Bundle).filter(Bundle.id == self.bundle.id).first() return result.is_cached + def routes(self) -> Dict[str, List[AssetReport]]: + """ + Returns a mapping of routes and all Assets (as AssetReports) that belongs to it + Note that this ignores dynamically imported Assets (ie only the direct asset) + """ + route_map = defaultdict(list) + for asset_report in self.asset_reports(): + for route in asset_report.routes(): + route_map[route].append(asset_report) + return route_map + + @sentry_sdk.trace + def full_route_report(self) -> BundleRouteReport: + """ + A more powerful routes function that will additionally associate dynamically + imported Assets into the belonging route. Also this function returns a + BundleRouteReport object as this will be used for comparison and additional + data manipulation. + """ + return_data = defaultdict(list) # typing: Dict[str, List[AssetReport]] + for route, asset_reports in self.routes().items(): + # Implements a graph traversal algorithm to get all nodes (Asset) linked by edges + # represented as DynamicImport. + visited_asset_ids = set() + unique_assets = [] + + # For each Asset get all the dynamic imported Asset that we will need to traverse into + to_be_processed_asset = deque(asset_reports) + while to_be_processed_asset: + current_asset = to_be_processed_asset.popleft() + if current_asset.id not in visited_asset_ids: + visited_asset_ids.add(current_asset.id) + unique_assets.append(current_asset) + to_be_processed_asset += current_asset.dynamically_imported_assets() + + # Add all the assets found to the route we were processing + return_data[route] = unique_assets + return BundleRouteReport(self.db_path, return_data) + class BundleAnalysisReport: """ diff --git a/tests/samples/sample_bundle_stats_dynamic_import_routing_1.json b/tests/samples/sample_bundle_stats_dynamic_import_routing_1.json new file mode 100644 index 00000000..6bafae92 --- /dev/null +++ b/tests/samples/sample_bundle_stats_dynamic_import_routing_1.json @@ -0,0 +1,106 @@ +{ + "version": "3", + "builtAt": 1732907862271, + "duration": 252, + "bundleName": "dynamic_imports", + "outputPath": "/dist", + "bundler": { "name": "rollup", "version": "4.22.4" }, + "plugin": { "name": "@codecov/sveltekit-plugin", "version": "0.0.1-beta.11" }, + "assets": [ + { + "name": "A1.js", + "size": 1, + "gzipSize": 1, + "normalized": "index1-*.js" + }, + { + "name": "A2.js", + "size": 10, + "gzipSize": 10, + "normalized": "index2-*.js" + }, + { + "name": "A3.js", + "size": 100, + "gzipSize": 100, + "normalized": "index3-*.js" + }, + { + "name": "A4.js", + "size": 1000, + "gzipSize": 1000, + "normalized": "index4-*.js" + }, + { + "name": "A5.js", + "size": 1000, + "gzipSize": 1000, + "normalized": "index5-*.js" + } + ], + "chunks": [ + { + "id": "C1", + "uniqueId": "C1", + "entry": false, + "initial": true, + "files": ["A1.js"], + "names": ["index"], + "dynamicImports": ["A2.js"] + }, + { + "id": "C2", + "uniqueId": "C2", + "entry": false, + "initial": true, + "files": ["A2.js"], + "names": ["index"], + "dynamicImports": ["A3.js"] + }, + { + "id": "C3", + "uniqueId": "C3", + "entry": false, + "initial": true, + "files": ["A3.js"], + "names": ["index"], + "dynamicImports": ["A4.js"] + }, + { + "id": "C4", + "uniqueId": "C4", + "entry": false, + "initial": true, + "files": ["A3.js"], + "names": ["index"], + "dynamicImports": ["A5.js"] + } + ], + "modules": [ + { + "name": "./src/routes/sverdle/about/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/users/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/faq/+page.ts", + "size": 189, + "chunkUniqueIds": ["C2"] + }, + { + "name": "./.svelte-kit/generated/client-optimized/nodes/5-no-route.js", + "size": 189, + "chunkUniqueIds": ["C3"] + }, + { + "name": "./src/routes/sverdle/careers/+page.ts", + "size": 189, + "chunkUniqueIds": ["C3"] + } + ] + } \ No newline at end of file diff --git a/tests/samples/sample_bundle_stats_v3_comparison_base_1.json b/tests/samples/sample_bundle_stats_v3_comparison_base_1.json new file mode 100644 index 00000000..195632b4 --- /dev/null +++ b/tests/samples/sample_bundle_stats_v3_comparison_base_1.json @@ -0,0 +1,66 @@ +{ + "version": "3", + "builtAt": 1732907862271, + "duration": 252, + "bundleName": "bundle1", + "outputPath": "/dist", + "bundler": { "name": "rollup", "version": "4.22.4" }, + "plugin": { "name": "@codecov/sveltekit-plugin", "version": "0.0.1-beta.11" }, + "assets": [ + { + "name": "A1.js", + "size": 1, + "gzipSize": 1, + "normalized": "index1-*.js" + }, + { + "name": "A2.js", + "size": 10, + "gzipSize": 10, + "normalized": "index2-*.js" + }, + { + "name": "A3.js", + "size": 100, + "gzipSize": 100, + "normalized": "index3-*.js" + } + ], + "chunks": [ + { + "id": "C1", + "uniqueId": "C1", + "entry": false, + "initial": true, + "files": ["A1.js"], + "names": ["index"], + "dynamicImports": ["A2.js"] + }, + { + "id": "C2", + "uniqueId": "C2", + "entry": false, + "initial": true, + "files": ["A2.js"], + "names": ["index"], + "dynamicImports": ["A3.js"] + } + ], + "modules": [ + { + "name": "./src/routes/sverdle/about/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/users/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/faq/+page.ts", + "size": 189, + "chunkUniqueIds": ["C2"] + } + ] + } \ No newline at end of file diff --git a/tests/samples/sample_bundle_stats_v3_comparison_base_2.json b/tests/samples/sample_bundle_stats_v3_comparison_base_2.json new file mode 100644 index 00000000..8cd6ee67 --- /dev/null +++ b/tests/samples/sample_bundle_stats_v3_comparison_base_2.json @@ -0,0 +1,66 @@ +{ + "version": "3", + "builtAt": 1732907862271, + "duration": 252, + "bundleName": "bundle2", + "outputPath": "/dist", + "bundler": { "name": "rollup", "version": "4.22.4" }, + "plugin": { "name": "@codecov/sveltekit-plugin", "version": "0.0.1-beta.11" }, + "assets": [ + { + "name": "A1.js", + "size": 1, + "gzipSize": 1, + "normalized": "index1-*.js" + }, + { + "name": "A2.js", + "size": 10, + "gzipSize": 10, + "normalized": "index2-*.js" + }, + { + "name": "A3.js", + "size": 100, + "gzipSize": 100, + "normalized": "index3-*.js" + } + ], + "chunks": [ + { + "id": "C1", + "uniqueId": "C1", + "entry": false, + "initial": true, + "files": ["A1.js"], + "names": ["index"], + "dynamicImports": ["A2.js"] + }, + { + "id": "C2", + "uniqueId": "C2", + "entry": false, + "initial": true, + "files": ["A2.js"], + "names": ["index"], + "dynamicImports": ["A3.js"] + } + ], + "modules": [ + { + "name": "./src/routes/sverdle/about/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/users/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/faq/+page.ts", + "size": 189, + "chunkUniqueIds": ["C2"] + } + ] + } \ No newline at end of file diff --git a/tests/samples/sample_bundle_stats_v3_comparison_head_1.json b/tests/samples/sample_bundle_stats_v3_comparison_head_1.json new file mode 100644 index 00000000..b2e668de --- /dev/null +++ b/tests/samples/sample_bundle_stats_v3_comparison_head_1.json @@ -0,0 +1,66 @@ +{ + "version": "3", + "builtAt": 1732907862271, + "duration": 252, + "bundleName": "bundle1", + "outputPath": "/dist", + "bundler": { "name": "rollup", "version": "4.22.4" }, + "plugin": { "name": "@codecov/sveltekit-plugin", "version": "0.0.1-beta.11" }, + "assets": [ + { + "name": "A1.js", + "size": 1, + "gzipSize": 1, + "normalized": "index1-*.js" + }, + { + "name": "A2.js", + "size": 10, + "gzipSize": 10, + "normalized": "index2-*.js" + }, + { + "name": "A3-prime.js", + "size": 1000, + "gzipSize": 1000, + "normalized": "index3-*.js" + } + ], + "chunks": [ + { + "id": "C1", + "uniqueId": "C1", + "entry": false, + "initial": true, + "files": ["A1.js"], + "names": ["index"], + "dynamicImports": ["A2.js"] + }, + { + "id": "C2", + "uniqueId": "C2", + "entry": false, + "initial": true, + "files": ["A2.js"], + "names": ["index"], + "dynamicImports": ["A3-prime.js"] + } + ], + "modules": [ + { + "name": "./src/routes/sverdle/about/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/users/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/faq-prime/+page.ts", + "size": 189, + "chunkUniqueIds": ["C2"] + } + ] + } \ No newline at end of file diff --git a/tests/samples/sample_bundle_stats_v3_comparison_head_2.json b/tests/samples/sample_bundle_stats_v3_comparison_head_2.json new file mode 100644 index 00000000..47702ecc --- /dev/null +++ b/tests/samples/sample_bundle_stats_v3_comparison_head_2.json @@ -0,0 +1,66 @@ +{ + "version": "3", + "builtAt": 1732907862271, + "duration": 252, + "bundleName": "bundle2", + "outputPath": "/dist", + "bundler": { "name": "rollup", "version": "4.22.4" }, + "plugin": { "name": "@codecov/sveltekit-plugin", "version": "0.0.1-beta.11" }, + "assets": [ + { + "name": "A1.js", + "size": 10, + "gzipSize": 10, + "normalized": "index1-*.js" + }, + { + "name": "A2.js", + "size": 100, + "gzipSize": 100, + "normalized": "index2-*.js" + }, + { + "name": "A3-prime.js", + "size": 10000, + "gzipSize": 10000, + "normalized": "index3-*.js" + } + ], + "chunks": [ + { + "id": "C1", + "uniqueId": "C1", + "entry": false, + "initial": true, + "files": ["A1.js"], + "names": ["index"], + "dynamicImports": ["A2.js"] + }, + { + "id": "C2", + "uniqueId": "C2", + "entry": false, + "initial": true, + "files": ["A2.js"], + "names": ["index"], + "dynamicImports": ["A3-prime.js"] + } + ], + "modules": [ + { + "name": "./src/routes/sverdle/about/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/users/+page.ts", + "size": 189, + "chunkUniqueIds": ["C1"] + }, + { + "name": "./src/routes/sverdle/faq-prime/+page.ts", + "size": 189, + "chunkUniqueIds": ["C2"] + } + ] + } \ No newline at end of file diff --git a/tests/unit/bundle_analysis/test_bundle_analysis.py b/tests/unit/bundle_analysis/test_bundle_analysis.py index 479486f4..fd3d802c 100644 --- a/tests/unit/bundle_analysis/test_bundle_analysis.py +++ b/tests/unit/bundle_analysis/test_bundle_analysis.py @@ -66,6 +66,12 @@ / "sample_bundle_stats_dynamic_imports_2.json" ) +sample_bundle_stats_path_9 = ( + Path(__file__).parent.parent.parent + / "samples" + / "sample_bundle_stats_dynamic_import_routing_1.json" +) + def _table_rows_count(db_session: DbSession) -> Tuple[int]: return ( @@ -963,3 +969,88 @@ def test_bundle_report_dynamic_imports_object_model(): finally: report.cleanup() + + +def test_bundle_report_dynamic_imports_fetching(): + try: + report = BundleAnalysisReport() + report.ingest(sample_bundle_stats_path_7) + bundle_report = report.bundle_report("dynamic_imports") + + # There should only be 3 dynamic imports total + dynamic_imports = [] + for asset in list(bundle_report.asset_reports()): + dynamic_imports.extend(asset.dynamically_imported_assets()) + assert len(dynamic_imports) == 3 + + # 1 of them should be from the asset called: LazyComponent-BBSC53Nv.js + asset = bundle_report.asset_report_by_name("LazyComponent-BBSC53Nv.js") + imports = [item for item in asset.dynamically_imported_assets()] + assert len(imports) == 1 + assert imports[0].hashed_name == "index-C-Z8zsvD.js" + + # 2 of them should be from the asset called: assets/index-oTNkmlIs.js + asset = bundle_report.asset_report_by_name("assets/index-oTNkmlIs.js") + imports = [item.hashed_name for item in asset.dynamically_imported_assets()] + assert len(imports) == 2 + assert "index-C-Z8zsvD.js" in imports + assert "LazyComponent-BBSC53Nv.js" in imports + finally: + report.cleanup() + + +def test_bundle_report_routes(): + try: + report = BundleAnalysisReport() + report.ingest(sample_bundle_stats_path_6) + bundle_report = report.bundle_report("sample") + route_map = bundle_report.routes() + + # Total of 4 routes in this bundle + assert len(route_map) == 4 + + # "/" route has two Assets + assets = sorted([item.hashed_name for item in route_map["/"]]) + assert assets == [ + "_app/immutable/nodes/0.CL_S-12h.js", + "_app/immutable/nodes/2.BMQFqo-e.js", + ] + + # "/about" has one asset + assets = sorted([item.hashed_name for item in route_map["/about"]]) + assert assets == ["_app/immutable/nodes/3.BqQOub2U.js"] + + # "/sverdle" has one asset + assets = sorted([item.hashed_name for item in route_map["/sverdle"]]) + assert assets == ["_app/immutable/nodes/4.CcjRtXvw.js"] + + # "/sverdle/how-to-play" has one asset + assets = sorted( + [item.hashed_name for item in route_map["/sverdle/how-to-play"]] + ) + assert assets == ["_app/immutable/nodes/5.CwxmUzn6.js"] + finally: + report.cleanup() + + +def test_bundle_report_route_report_with_dynamic_imports(): + try: + report = BundleAnalysisReport() + report.ingest(sample_bundle_stats_path_9) + bundle_report = report.bundle_report("dynamic_imports") + + route_report = bundle_report.full_route_report() + + assert route_report.get_sizes() == { + "/sverdle/about": 2111, + "/sverdle/careers": 2100, + "/sverdle/faq": 2110, + "/sverdle/users": 2111, + } + assert route_report.get_size("/sverdle/fake") is None + assert route_report.get_size("/sverdle/about") == 2111 + assert route_report.get_size("/sverdle/careers") == 2100 + assert route_report.get_size("/sverdle/faq") == 2110 + assert route_report.get_size("/sverdle/users") == 2111 + finally: + report.cleanup() diff --git a/tests/unit/bundle_analysis/test_bundle_comparison.py b/tests/unit/bundle_analysis/test_bundle_comparison.py index da0ba9e4..07121470 100644 --- a/tests/unit/bundle_analysis/test_bundle_comparison.py +++ b/tests/unit/bundle_analysis/test_bundle_comparison.py @@ -11,6 +11,7 @@ MissingBaseReportError, MissingBundleError, MissingHeadReportError, + RouteChange, ) from shared.bundle_analysis.models import Bundle, get_db_session from shared.storage.memory import MemoryStorageService @@ -22,6 +23,26 @@ head_report_bundle_stats_path = ( here.parent.parent.parent / "samples" / "sample_bundle_stats_other.json" ) +head_report_bundle_stats_path_route_base_1 = ( + here.parent.parent.parent + / "samples" + / "sample_bundle_stats_v3_comparison_base_1.json" +) +head_report_bundle_stats_path_route_base_2 = ( + here.parent.parent.parent + / "samples" + / "sample_bundle_stats_v3_comparison_base_2.json" +) +head_report_bundle_stats_path_route_head_1 = ( + here.parent.parent.parent + / "samples" + / "sample_bundle_stats_v3_comparison_head_1.json" +) +head_report_bundle_stats_path_route_head_2 = ( + here.parent.parent.parent + / "samples" + / "sample_bundle_stats_v3_comparison_head_2.json" +) def test_bundle_analysis_comparison(): @@ -173,3 +194,200 @@ def test_bundle_analysis_total_size_delta(): finally: base_report.cleanup() head_report.cleanup() + + +def test_bundle_analysis_route_comparison_by_bundle_name(): + loader = BundleAnalysisReportLoader( + storage_service=MemoryStorageService({}), + repo_key="testing", + ) + + comparison = BundleAnalysisComparison( + loader=loader, + base_report_key="base-report", + head_report_key="head-report", + ) + + # raises errors when either report doesn't exist in storage + with pytest.raises(MissingBaseReportError): + comparison.base_report + with pytest.raises(MissingHeadReportError): + comparison.head_report + + try: + base_report = BundleAnalysisReport() + base_report.ingest(head_report_bundle_stats_path_route_base_1) + base_report.ingest(head_report_bundle_stats_path_route_base_2) + + head_report = BundleAnalysisReport() + head_report.ingest(head_report_bundle_stats_path_route_head_1) + head_report.ingest(head_report_bundle_stats_path_route_head_2) + + loader.save(base_report, "base-report") + loader.save(head_report, "head-report") + finally: + base_report.cleanup() + head_report.cleanup() + + route_changes = comparison.bundle_routes_changes_by_bundle("bundle1") + sorted_route_changes = sorted(route_changes, key=lambda x: x.route_name) + expected_changes = [ + RouteChange( + route_name="/sverdle/about", + change_type=AssetChange.ChangeType.CHANGED, + size_delta=900, + percentage_delta=810.81, + ), + RouteChange( + route_name="/sverdle/faq", + change_type=AssetChange.ChangeType.REMOVED, + size_delta=-110, + percentage_delta=-100.0, + ), + RouteChange( + route_name="/sverdle/faq-prime", + change_type=AssetChange.ChangeType.ADDED, + size_delta=1010, + percentage_delta=100, + ), + RouteChange( + route_name="/sverdle/users", + change_type=AssetChange.ChangeType.CHANGED, + size_delta=900, + percentage_delta=810.81, + ), + ] + + assert sorted_route_changes == expected_changes + + +def test_bundle_analysis_route_comparison_all_bundles(): + loader = BundleAnalysisReportLoader( + storage_service=MemoryStorageService({}), + repo_key="testing", + ) + + comparison = BundleAnalysisComparison( + loader=loader, + base_report_key="base-report", + head_report_key="head-report", + ) + + # raises errors when either report doesn't exist in storage + with pytest.raises(MissingBaseReportError): + comparison.base_report + with pytest.raises(MissingHeadReportError): + comparison.head_report + + try: + base_report = BundleAnalysisReport() + base_report.ingest(head_report_bundle_stats_path_route_base_1) + base_report.ingest(head_report_bundle_stats_path_route_base_2) + + head_report = BundleAnalysisReport() + head_report.ingest(head_report_bundle_stats_path_route_head_1) + head_report.ingest(head_report_bundle_stats_path_route_head_2) + + loader.save(base_report, "base-report") + loader.save(head_report, "head-report") + finally: + base_report.cleanup() + head_report.cleanup() + + route_changes = comparison.bundle_routes_changes() + + assert len(route_changes) == 2 + assert "bundle1" in route_changes and "bundle2" in route_changes + + sorted_route_changes = sorted(route_changes["bundle1"], key=lambda x: x.route_name) + expected_bundle1_changes = [ + RouteChange( + route_name="/sverdle/about", + change_type=AssetChange.ChangeType.CHANGED, + size_delta=900, + percentage_delta=810.81, + ), + RouteChange( + route_name="/sverdle/faq", + change_type=AssetChange.ChangeType.REMOVED, + size_delta=-110, + percentage_delta=-100.0, + ), + RouteChange( + route_name="/sverdle/faq-prime", + change_type=AssetChange.ChangeType.ADDED, + size_delta=1010, + percentage_delta=100, + ), + RouteChange( + route_name="/sverdle/users", + change_type=AssetChange.ChangeType.CHANGED, + size_delta=900, + percentage_delta=810.81, + ), + ] + assert sorted_route_changes == expected_bundle1_changes + + sorted_route_changes = sorted(route_changes["bundle2"], key=lambda x: x.route_name) + expected_bundle2_changes = [ + RouteChange( + route_name="/sverdle/about", + change_type=AssetChange.ChangeType.CHANGED, + size_delta=9999, + percentage_delta=9008.11, + ), + RouteChange( + route_name="/sverdle/faq", + change_type=AssetChange.ChangeType.REMOVED, + size_delta=-110, + percentage_delta=-100.0, + ), + RouteChange( + route_name="/sverdle/faq-prime", + change_type=AssetChange.ChangeType.ADDED, + size_delta=10100, + percentage_delta=100, + ), + RouteChange( + route_name="/sverdle/users", + change_type=AssetChange.ChangeType.CHANGED, + size_delta=9999, + percentage_delta=9008.11, + ), + ] + assert sorted_route_changes == expected_bundle2_changes + + +def test_bundle_analysis_route_comparison_by_bundle_name_not_exist(): + loader = BundleAnalysisReportLoader( + storage_service=MemoryStorageService({}), + repo_key="testing", + ) + + comparison = BundleAnalysisComparison( + loader=loader, + base_report_key="base-report", + head_report_key="head-report", + ) + + # raises errors when either report doesn't exist in storage + with pytest.raises(MissingBaseReportError): + comparison.base_report + with pytest.raises(MissingHeadReportError): + comparison.head_report + + try: + base_report = BundleAnalysisReport() + base_report.ingest(head_report_bundle_stats_path_route_base_1) + + head_report = BundleAnalysisReport() + head_report.ingest(head_report_bundle_stats_path_route_head_1) + + loader.save(base_report, "base-report") + loader.save(head_report, "head-report") + finally: + base_report.cleanup() + head_report.cleanup() + + with pytest.raises(MissingBundleError): + comparison.bundle_routes_changes_by_bundle("bundle2")