From 45252f75524c38172b991a960bac964ec7b6f7d1 Mon Sep 17 00:00:00 2001 From: JerrySentry <142266253+JerrySentry@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:19:07 -0500 Subject: [PATCH] Bundle Analysis: add ability to ingest dynamic imports (#445) --- shared/bundle_analysis/db_migrations.py | 4 + .../migrations/v004_add_dynamic_imports.py | 27 ++ shared/bundle_analysis/models.py | 29 +- shared/bundle_analysis/parser.py | 3 +- shared/bundle_analysis/parsers/__init__.py | 2 + shared/bundle_analysis/parsers/v3.py | 424 ++++++++++++++++++ ...sample_bundle_stats_dynamic_imports_1.json | 207 +++++++++ ...sample_bundle_stats_dynamic_imports_2.json | 207 +++++++++ .../bundle_analysis/test_bundle_analysis.py | 88 ++++ 9 files changed, 989 insertions(+), 2 deletions(-) create mode 100644 shared/bundle_analysis/migrations/v004_add_dynamic_imports.py create mode 100644 shared/bundle_analysis/parsers/v3.py create mode 100644 tests/samples/sample_bundle_stats_dynamic_imports_1.json create mode 100644 tests/samples/sample_bundle_stats_dynamic_imports_2.json diff --git a/shared/bundle_analysis/db_migrations.py b/shared/bundle_analysis/db_migrations.py index eecfd1dc..403834fe 100644 --- a/shared/bundle_analysis/db_migrations.py +++ b/shared/bundle_analysis/db_migrations.py @@ -9,6 +9,9 @@ from shared.bundle_analysis.migrations.v003_modify_gzip_size_nullable import ( modify_gzip_size_nullable, ) +from shared.bundle_analysis.migrations.v004_add_dynamic_imports import ( + add_dynamic_imports, +) class BundleAnalysisMigration: @@ -33,6 +36,7 @@ def __init__(self, db_session: Session, from_version: int, to_version: int): 2: add_gzip_size, 3: add_is_cached, 4: modify_gzip_size_nullable, + 5: add_dynamic_imports, } def update_schema_version(self, version): diff --git a/shared/bundle_analysis/migrations/v004_add_dynamic_imports.py b/shared/bundle_analysis/migrations/v004_add_dynamic_imports.py new file mode 100644 index 00000000..ce9af1ca --- /dev/null +++ b/shared/bundle_analysis/migrations/v004_add_dynamic_imports.py @@ -0,0 +1,27 @@ +from sqlalchemy import text +from sqlalchemy.orm import Session + + +def add_dynamic_imports(db_session: Session): + """ + Adds a table called dynamic_imports (DynamicImport model name) + This table represents for a given Chunk what are its dynamically + imported Assets, if applicable. + + There is no data available to migrate, any older versions of bundle + reports will be considered to not have dynamic imports + """ + stmts = [ + """ + CREATE TABLE dynamic_imports ( + chunk_id integer not null, + asset_id integer not null, + primary key (chunk_id, asset_id), + foreign key (chunk_id) references chunks (id), + foreign key (asset_id) references assets (id) + ); + """, + ] + + for stmt in stmts: + db_session.execute(text(stmt)) diff --git a/shared/bundle_analysis/models.py b/shared/bundle_analysis/models.py index a02396c2..d8d775df 100644 --- a/shared/bundle_analysis/models.py +++ b/shared/bundle_analysis/models.py @@ -84,9 +84,17 @@ foreign key (chunk_id) references chunks (id), foreign key (module_id) references modules (id) ); + +create table dynamic_imports ( + chunk_id integer not null, + asset_id integer not null, + primary key (chunk_id, asset_id), + foreign key (chunk_id) references chunks (id), + foreign key (asset_id) references assets (id) +); """ -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 Base = declarative_base() @@ -275,6 +283,25 @@ class Module(Base): ) +class DynamicImport(Base): + """ + These represents a mapping of each chunk's dynamically imported assets + """ + + __tablename__ = "dynamic_imports" + + chunk_id = Column(types.Integer, ForeignKey("chunks.id"), primary_key=True) + asset_id = Column(types.Integer, ForeignKey("assets.id"), primary_key=True) + + # Relationships + chunk = relationship( + "Chunk", backref=backref("dynamic_imports", cascade="all, delete-orphan") + ) + asset = relationship( + "Asset", backref=backref("dynamic_imports", cascade="all, delete-orphan") + ) + + class MetadataKey(Enum): SCHEMA_VERSION = "schema_version" COMPARE_SHA = "compare_sha" diff --git a/shared/bundle_analysis/parser.py b/shared/bundle_analysis/parser.py index 53bbbead..7feef8fc 100644 --- a/shared/bundle_analysis/parser.py +++ b/shared/bundle_analysis/parser.py @@ -3,7 +3,7 @@ import ijson from sqlalchemy.orm import Session as DbSession -from shared.bundle_analysis.parsers import ParserInterface, ParserV1, ParserV2 +from shared.bundle_analysis.parsers import ParserInterface, ParserV1, ParserV2, ParserV3 from shared.bundle_analysis.parsers.base import ParserTrait log = logging.getLogger(__name__) @@ -12,6 +12,7 @@ PARSER_VERSION_MAPPING: dict[str, type[ParserTrait]] = { "1": ParserV1, "2": ParserV2, + "3": ParserV3, } diff --git a/shared/bundle_analysis/parsers/__init__.py b/shared/bundle_analysis/parsers/__init__.py index 3842f214..98c77c4d 100644 --- a/shared/bundle_analysis/parsers/__init__.py +++ b/shared/bundle_analysis/parsers/__init__.py @@ -1,9 +1,11 @@ from shared.bundle_analysis.parsers.base import ParserInterface from shared.bundle_analysis.parsers.v1 import ParserV1 from shared.bundle_analysis.parsers.v2 import ParserV2 +from shared.bundle_analysis.parsers.v3 import ParserV3 __all__ = [ "ParserInterface", "ParserV1", "ParserV2", + "ParserV3", ] diff --git a/shared/bundle_analysis/parsers/v3.py b/shared/bundle_analysis/parsers/v3.py new file mode 100644 index 00000000..efe4ad4a --- /dev/null +++ b/shared/bundle_analysis/parsers/v3.py @@ -0,0 +1,424 @@ +import json +import logging +import re +import uuid +from collections import defaultdict +from typing import List, Tuple + +import ijson +import sentry_sdk +from sqlalchemy.orm import Session as DbSession +from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound + +from shared.bundle_analysis.models import ( + Asset, + AssetType, + Bundle, + Chunk, + DynamicImport, + Module, + Session, + assets_chunks, + chunks_modules, +) +from shared.bundle_analysis.parsers.base import ParserTrait +from shared.bundle_analysis.utils import get_extension + +log = logging.getLogger(__name__) + + +""" +Version 3 Schema +{ + "version": "3", + "plugin": { + "name": str + "version": str + }, + "builtAt": int, + "duration": int, + "bundler": { "name": str, "version": str }, + "bundleName": str, + "assets": [{ + "name": str", + "size": int, + "gzipSize": int, + "normalized": str + }], + "chunks": [{ + "id": str, + "uniqueId": str, + "entry": bool, + "initial": bool, + "files": [str], + "names": [str], + "dynamicImports": [str] + }], + "modules": [{ + "name": str, + "size": int, + "chunkUniqueIds": [str] + }] +} +""" + + +class ParserV3(ParserTrait): + """ + This does a streaming JSON parse of the stats JSON file referenced by `path`. + It's more complicated that just doing a `json.loads` but should keep our memory + usage constrained. + """ + + def __init__(self, db_session: DbSession): + self.db_session = db_session + + def reset(self): + """ + Resets temporary parser state in order to parse a new file path. + """ + # chunk unique id -> asset name list + self.chunk_asset_names_index = {} + + # module name -> chunk external id list + self.module_chunk_unique_external_ids_index = {} + + # misc. top-level info from the stats data (i.e. bundler version, bundle time, etc.) + self.info = {} + + # temporary parser state + self.session = None + self.asset = None + self.chunk = None + self.chunk_asset_names = [] + self.module = None + self.module_chunk_unique_external_ids = [] + + self.asset_list = [] + self.chunk_list = [] + self.module_list = [] + + # dynamic imports: mapping between Chunk and each file name of its dynamic imports + self.dynamic_imports_mapping = defaultdict( + list + ) # typing: Dict[Chunk, List[str]] + + @sentry_sdk.trace + def parse(self, path: str) -> Tuple[int, str]: + try: + self.reset() + + # Retrieve the info section first before parsing all the other things + # this way when an error is raised we know which bundle plugin caused it + with open(path, "rb") as f: + for event in ijson.parse(f): + self._parse_info(event) + + self.session = Session(info={}) + self.db_session.add(self.session) + self.db_session.flush() + + with open(path, "rb") as f: + for event in ijson.parse(f): + self._parse_event(event) + + # Delete old session/asset/chunk/module with the same bundle name if applicable + old_session = ( + self.db_session.query(Session) + .filter( + Session.bundle == self.session.bundle, + Session.id != self.session.id, + ) + .one_or_none() + ) + if old_session: + for model in [Asset, Chunk, Module]: + to_be_deleted = self.db_session.query(model).filter( + model.session == old_session + ) + for item in to_be_deleted: + self.db_session.delete(item) + self.db_session.flush() + self.db_session.delete(old_session) + self.db_session.flush() + + if self.asset_list: + insert_asset = Asset.__table__.insert().values(self.asset_list) + self.db_session.execute(insert_asset) + + # Needs to use ORM-style insert to update the models since they + # will be used later in dynamic import processing + self.db_session.add_all(self.chunk_list) + + if self.module_list: + insert_modules = Module.__table__.insert().values(self.module_list) + self.db_session.execute(insert_modules) + + self.db_session.flush() + + # Insert into dynamic imports table the Chunk.id and Asset.id + # but first we need to find the Asset by the hashed file name + dynamic_imports_list = self._parse_dynamic_imports() + if dynamic_imports_list: + insert_dynamic_imports = DynamicImport.__table__.insert().values( + dynamic_imports_list + ) + self.db_session.execute(insert_dynamic_imports) + self.db_session.flush() + + # save top level bundle stats info + self.session.info = json.dumps(self.info) + + # this happens last so that we could potentially handle any ordering + # of top-level keys inside the JSON (i.e. we couldn't associate a chunk + # to an asset above if we parse the chunk before the asset) + self._create_associations() + + assert self.session.bundle is not None + return self.session.id, self.session.bundle.name + except Exception as e: + # Inject the plugin name to the Exception object so we have visibilitity on which plugin + # is causing the trouble. + e.bundle_analysis_plugin_name = self.info.get("plugin_name", "unknown") + raise e + + def _asset_type(self, name: str) -> AssetType: + extension = get_extension(name) + + if extension in ["js", "mjs", "cjs"]: + return AssetType.JAVASCRIPT + if extension in ["css"]: + return AssetType.STYLESHEET + if extension in ["woff", "woff2", "ttf", "otf", "eot"]: + return AssetType.FONT + if extension in ["jpg", "jpeg", "png", "gif", "svg", "webp", "apng", "avif"]: + return AssetType.IMAGE + + return AssetType.UNKNOWN + + def _parse_info(self, event: Tuple[str, str, str]): + prefix, _, value = event + + # session info + if prefix == "version": + self.info["version"] = value + elif prefix == "bundler.name": + self.info["bundler_name"] = value + elif prefix == "bundler.version": + self.info["bundler_version"] = value + elif prefix == "builtAt": + self.info["built_at"] = value + elif prefix == "plugin.name": + self.info["plugin_name"] = value + elif prefix == "plugin.version": + self.info["plugin_version"] = value + elif prefix == "duration": + self.info["duration"] = value + + def _parse_event(self, event: Tuple[str, str, str]): + prefix, _, value = event + prefix_path = prefix.split(".") + + # asset / chunks / modules + if prefix_path[0] == "assets": + self._parse_assets_event(*event) + elif prefix_path[0] == "chunks": + self._parse_chunks_event(*event) + elif prefix_path[0] == "modules": + self._parse_modules_event(*event) + + # bundle name + elif prefix == "bundleName": + if not re.fullmatch(r"^[\w\d_:/@\.{}\[\]$-]+$", value): + log.info(f'bundle name does not match regex: "{value}"') + raise Exception("invalid bundle name") + bundle = self.db_session.query(Bundle).filter_by(name=value).first() + if bundle is None: + bundle = Bundle(name=value) + self.db_session.add(bundle) + bundle.is_cached = False + self.session.bundle = bundle + + def _parse_assets_event(self, prefix: str, event: str, value: str): + if (prefix, event) == ("assets.item", "start_map"): + # new asset + assert self.asset is None + self.asset = Asset(session_id=self.session.id) + elif prefix == "assets.item.name": + self.asset.name = value + elif prefix == "assets.item.normalized": + self.asset.normalized_name = value + elif prefix == "assets.item.size": + self.asset.size = int(value) + elif prefix == "assets.item.gzipSize" and value is not None: + self.asset.gzip_size = int(value) + elif (prefix, event) == ("assets.item", "end_map"): + self.asset_list.append( + dict( + session_id=self.asset.session_id, + name=self.asset.name, + normalized_name=self.asset.normalized_name, + size=self.asset.size, + gzip_size=self.asset.gzip_size, + uuid=str(uuid.uuid4()), + asset_type=self._asset_type(self.asset.name), + ) + ) + + # reset parser state + self.asset = None + + def _parse_chunks_event(self, prefix: str, event: str, value: str): + if (prefix, event) == ("chunks.item", "start_map"): + # new chunk + assert self.chunk is None + self.chunk = Chunk(session_id=self.session.id) + elif prefix == "chunks.item.id": + self.chunk.external_id = value + elif prefix == "chunks.item.uniqueId": + self.chunk.unique_external_id = value + elif prefix == "chunks.item.initial": + self.chunk.initial = value + elif prefix == "chunks.item.entry": + self.chunk.entry = value + 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) + elif (prefix, event) == ("chunks.item", "end_map"): + self.chunk_list.append(self.chunk) + + self.chunk_asset_names_index[self.chunk.unique_external_id] = ( + self.chunk_asset_names + ) + # reset parser state + self.chunk = None + self.chunk_asset_names = [] + + def _parse_modules_event(self, prefix: str, event: str, value: str): + if (prefix, event) == ("modules.item", "start_map"): + # new module + assert self.module is None + self.module = Module(session_id=self.session.id) + elif prefix == "modules.item.name": + self.module.name = value + elif prefix == "modules.item.size": + self.module.size = int(value) + elif prefix == "modules.item.chunkUniqueIds.item": + self.module_chunk_unique_external_ids.append(value) + elif (prefix, event) == ("modules.item", "end_map"): + self.module_list.append( + dict( + session_id=self.module.session_id, + name=self.module.name, + size=self.module.size, + ) + ) + + self.module_chunk_unique_external_ids_index[self.module.name] = ( + self.module_chunk_unique_external_ids + ) + # reset parser state + self.module = None + self.module_chunk_unique_external_ids = [] + + def _parse_dynamic_imports(self) -> List[dict]: + dynamic_imports_list = [] + for chunk, filenames in self.dynamic_imports_mapping.items(): + imported_assets = {} + for filename in filenames: + try: + asset = ( + self.db_session.query(Asset) + .join(Asset.session) # Join Asset to Session + .join(Session.bundle) # Join Session to Bundle + .filter( + Asset.name == filename, + Bundle.name == self.session.bundle.name, + ) + .one() + ) + imported_assets[filename] = asset + except (NoResultFound, MultipleResultsFound): + log.info( + f'Asset not found for dynamic import: "{filename}"', + exc_info=True, + ) + raise + + dynamic_imports_list.extend( + [ + dict(chunk_id=chunk.id, asset_id=asset.id) + for asset in imported_assets.values() + ] + ) + + return dynamic_imports_list + + def _create_associations(self): + # associate chunks to assets + inserts = [] + assets: list[Asset] = ( + self.db_session.query(Asset) + .filter( + Asset.session_id == self.session.id, + ) + .all() + ) + + asset_name_to_id = {asset.name: asset.id for asset in assets} + + chunks: list[Chunk] = ( + self.db_session.query(Chunk) + .filter( + Chunk.session_id == self.session.id, + ) + .all() + ) + + chunk_unique_id_to_id = {chunk.unique_external_id: chunk.id for chunk in chunks} + + modules = ( + self.db_session.query(Module) + .filter( + Module.session_id == self.session.id, + ) + .all() + ) + + for chunk in chunks: + chunk_id = chunk.id + asset_names = self.chunk_asset_names_index[chunk.unique_external_id] + inserts.extend( + [ + dict(asset_id=asset_name_to_id[asset_name], chunk_id=chunk_id) + for asset_name in asset_names + ] + ) + if inserts: + self.db_session.execute(assets_chunks.insert(), inserts) + + # associate modules to chunks + # FIXME: this isn't quite right - need to sort out how non-JS assets reference chunks + inserts = [] + + modules: list[Module] = self.db_session.query(Module).filter( + Module.session_id == self.session.id, + ) + for module in modules: + module_id = module.id + chunk_unique_external_ids = self.module_chunk_unique_external_ids_index[ + module.name + ] + + inserts.extend( + [ + dict( + chunk_id=chunk_unique_id_to_id[unique_external_id], + module_id=module_id, + ) + for unique_external_id in chunk_unique_external_ids + ] + ) + if inserts: + self.db_session.execute(chunks_modules.insert(), inserts) diff --git a/tests/samples/sample_bundle_stats_dynamic_imports_1.json b/tests/samples/sample_bundle_stats_dynamic_imports_1.json new file mode 100644 index 00000000..892f1295 --- /dev/null +++ b/tests/samples/sample_bundle_stats_dynamic_imports_1.json @@ -0,0 +1,207 @@ +{ + "version": "3", + "builtAt": 1732907862271, + "duration": 252, + "bundleName": "dynamic_imports", + "outputPath": "/dist", + "bundler": { "name": "rollup", "version": "4.22.4" }, + "plugin": { "name": "@codecov/vite-plugin", "version": "1.4.0" }, + "assets": [ + { + "name": "react.CHdo91hT.js", + "size": 4126, + "gzipSize": 2053, + "normalized": "react.*.js" + }, + { + "name": "index.CRhFRBHw.js", + "size": 1433, + "gzipSize": 743, + "normalized": "index.*.js" + }, + { + "name": "index-C-Z8zsvD.js", + "size": 161, + "gzipSize": 152, + "normalized": "index-*.js" + }, + { + "name": "LazyComponent-BBSC53Nv.js", + "size": 299, + "gzipSize": 246, + "normalized": "LazyComponent-*.js" + }, + { + "name": "assets/index-oTNkmlIs.js", + "size": 144686, + "gzipSize": 46757, + "normalized": "assets/index-*.js" + } + ], + "chunks": [ + { + "id": "index", + "uniqueId": "0-index", + "entry": false, + "initial": true, + "files": ["index-C-Z8zsvD.js"], + "names": ["index"], + "dynamicImports": [] + }, + { + "id": "LazyComponent", + "uniqueId": "1-LazyComponent", + "entry": false, + "initial": true, + "files": ["LazyComponent-BBSC53Nv.js"], + "names": ["LazyComponent"], + "dynamicImports": ["index-C-Z8zsvD.js"] + }, + { + "id": "index", + "uniqueId": "2-index", + "entry": true, + "initial": false, + "files": ["assets/index-oTNkmlIs.js"], + "names": ["index"], + "dynamicImports": ["index-C-Z8zsvD.js", "LazyComponent-BBSC53Nv.js"] + } + ], + "modules": [ + { + "name": "./src/IndexedLazyComponent/IndexedLazyComponent.tsx", + "size": 189, + "chunkUniqueIds": ["0-index"] + }, + { + "name": "./src/IndexedLazyComponent/index.ts", + "size": 0, + "chunkUniqueIds": ["0-index"] + }, + { + "name": "./src/LazyComponent/LazyComponent.tsx", + "size": 495, + "chunkUniqueIds": ["1-LazyComponent"] + }, + { + "name": "./vite/modulepreload-polyfill.js", + "size": 1280, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "./commonjsHelpers.js", + "size": 140, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/jsx-runtime.js?commonjs-module", + "size": 31, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react-jsx-runtime.production.min.js?commonjs-exports", + "size": 40, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/index.js?commonjs-module", + "size": 26, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react.production.min.js?commonjs-exports", + "size": 30, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react.production.min.js", + "size": 7591, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/index.js", + "size": 144, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react-jsx-runtime.production.min.js", + "size": 919, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/jsx-runtime.js", + "size": 103, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/client.js?commonjs-exports", + "size": 16, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/index.js?commonjs-module", + "size": 29, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/cjs/react-dom.production.min.js?commonjs-exports", + "size": 33, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/index.js?commonjs-module", + "size": 30, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/cjs/scheduler.production.min.js?commonjs-exports", + "size": 34, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/cjs/scheduler.production.min.js", + "size": 4315, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/index.js", + "size": 94, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/cjs/react-dom.production.min.js", + "size": 132340, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/index.js", + "size": 381, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/client.js", + "size": 102, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "./vite/preload-helper.js", + "size": 1928, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "./src/assets/react.svg", + "size": 45, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../../../../../vite.svg", + "size": 51, + "chunkUniqueIds": ["2-index"] + }, + { "name": "./src/App.css", "size": 0, "chunkUniqueIds": ["2-index"] }, + { "name": "./src/App.tsx", "size": 1975, "chunkUniqueIds": ["2-index"] }, + { "name": "./src/index.css", "size": 0, "chunkUniqueIds": ["2-index"] }, + { "name": "./src/main.tsx", "size": 181, "chunkUniqueIds": ["2-index"] }, + { "name": "./index.html", "size": 0, "chunkUniqueIds": ["2-index"] } + ] + } \ No newline at end of file diff --git a/tests/samples/sample_bundle_stats_dynamic_imports_2.json b/tests/samples/sample_bundle_stats_dynamic_imports_2.json new file mode 100644 index 00000000..19e0306b --- /dev/null +++ b/tests/samples/sample_bundle_stats_dynamic_imports_2.json @@ -0,0 +1,207 @@ +{ + "version": "3", + "builtAt": 1732907862271, + "duration": 252, + "bundleName": "dynamic_imports", + "outputPath": "/dist", + "bundler": { "name": "rollup", "version": "4.22.4" }, + "plugin": { "name": "@codecov/vite-plugin", "version": "1.4.0" }, + "assets": [ + { + "name": "react.CHdo91hT.js", + "size": 4126, + "gzipSize": 2053, + "normalized": "react.*.js" + }, + { + "name": "index.CRhFRBHw.js", + "size": 1433, + "gzipSize": 743, + "normalized": "index.*.js" + }, + { + "name": "index-C-Z8zsvD.js", + "size": 161, + "gzipSize": 152, + "normalized": "index-*.js" + }, + { + "name": "LazyComponent-BBSC53Nv.js", + "size": 299, + "gzipSize": 246, + "normalized": "LazyComponent-*.js" + }, + { + "name": "assets/index-oTNkmlIs.js", + "size": 144686, + "gzipSize": 46757, + "normalized": "assets/index-*.js" + } + ], + "chunks": [ + { + "id": "index", + "uniqueId": "0-index", + "entry": false, + "initial": true, + "files": ["index-C-Z8zsvD.js"], + "names": ["index"], + "dynamicImports": ["LazyComponent-BBSC53Nv.js"] + }, + { + "id": "LazyComponent", + "uniqueId": "1-LazyComponent", + "entry": false, + "initial": true, + "files": ["LazyComponent-BBSC53Nv.js"], + "names": ["LazyComponent"], + "dynamicImports": ["index-C-Z8zsvD.js"] + }, + { + "id": "index", + "uniqueId": "2-index", + "entry": true, + "initial": false, + "files": ["assets/index-oTNkmlIs.js"], + "names": ["index"], + "dynamicImports": ["index-C-Z8zsvD.js"] + } + ], + "modules": [ + { + "name": "./src/IndexedLazyComponent/IndexedLazyComponent.tsx", + "size": 189, + "chunkUniqueIds": ["0-index"] + }, + { + "name": "./src/IndexedLazyComponent/index.ts", + "size": 0, + "chunkUniqueIds": ["0-index"] + }, + { + "name": "./src/LazyComponent/LazyComponent.tsx", + "size": 495, + "chunkUniqueIds": ["1-LazyComponent"] + }, + { + "name": "./vite/modulepreload-polyfill.js", + "size": 1280, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "./commonjsHelpers.js", + "size": 140, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/jsx-runtime.js?commonjs-module", + "size": 31, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react-jsx-runtime.production.min.js?commonjs-exports", + "size": 40, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/index.js?commonjs-module", + "size": 26, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react.production.min.js?commonjs-exports", + "size": 30, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react.production.min.js", + "size": 7591, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/index.js", + "size": 144, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react-jsx-runtime.production.min.js", + "size": 919, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react@18.2.0/node_modules/react/jsx-runtime.js", + "size": 103, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/client.js?commonjs-exports", + "size": 16, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/index.js?commonjs-module", + "size": 29, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/cjs/react-dom.production.min.js?commonjs-exports", + "size": 33, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/index.js?commonjs-module", + "size": 30, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/cjs/scheduler.production.min.js?commonjs-exports", + "size": 34, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/cjs/scheduler.production.min.js", + "size": 4315, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/scheduler@0.23.0/node_modules/scheduler/index.js", + "size": 94, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/cjs/react-dom.production.min.js", + "size": 132340, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/index.js", + "size": 381, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/client.js", + "size": 102, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "./vite/preload-helper.js", + "size": 1928, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "./src/assets/react.svg", + "size": 45, + "chunkUniqueIds": ["2-index"] + }, + { + "name": "../../../../../../vite.svg", + "size": 51, + "chunkUniqueIds": ["2-index"] + }, + { "name": "./src/App.css", "size": 0, "chunkUniqueIds": ["2-index"] }, + { "name": "./src/App.tsx", "size": 1975, "chunkUniqueIds": ["2-index"] }, + { "name": "./src/index.css", "size": 0, "chunkUniqueIds": ["2-index"] }, + { "name": "./src/main.tsx", "size": 181, "chunkUniqueIds": ["2-index"] }, + { "name": "./index.html", "size": 0, "chunkUniqueIds": ["2-index"] } + ] + } \ 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 f809e8cd..479486f4 100644 --- a/tests/unit/bundle_analysis/test_bundle_analysis.py +++ b/tests/unit/bundle_analysis/test_bundle_analysis.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from sqlalchemy import select from sqlalchemy.orm import Session as DbSession from shared.bundle_analysis import BundleAnalysisReport, BundleAnalysisReportLoader @@ -13,6 +14,7 @@ AssetType, Bundle, Chunk, + DynamicImport, Metadata, MetadataKey, Module, @@ -52,6 +54,18 @@ / "sample_bundle_stats_asset_routes.json" ) +sample_bundle_stats_path_7 = ( + Path(__file__).parent.parent.parent + / "samples" + / "sample_bundle_stats_dynamic_imports_1.json" +) + +sample_bundle_stats_path_8 = ( + Path(__file__).parent.parent.parent + / "samples" + / "sample_bundle_stats_dynamic_imports_2.json" +) + def _table_rows_count(db_session: DbSession) -> Tuple[int]: return ( @@ -875,3 +889,77 @@ def test_bundle_report_total_gzip_size(): assert bundle_report.total_gzip_size() == 150567 finally: report.cleanup() + + +def test_bundle_report_dynamic_imports_object_model(): + try: + report = BundleAnalysisReport() + with get_db_session(report.db_path) as db_session: + # Check that the DB is set up correctly + # 1 chunk has 2, another chunk has 1 + report.ingest(sample_bundle_stats_path_7) + + query = ( + select(Chunk.unique_external_id, Asset.name) + .join(DynamicImport, Chunk.id == DynamicImport.chunk_id) + .join(Asset, Asset.id == DynamicImport.asset_id) + .order_by(Chunk.unique_external_id) + ) + + results = db_session.execute(query).all() + + result_dicts = [ + {"unique_external_id": unique_external_id, "asset_name": asset_name} + for unique_external_id, asset_name in results + ] + + assert result_dicts == [ + { + "asset_name": "index-C-Z8zsvD.js", + "unique_external_id": "1-LazyComponent", + }, + { + "asset_name": "index-C-Z8zsvD.js", + "unique_external_id": "2-index", + }, + { + "asset_name": "LazyComponent-BBSC53Nv.js", + "unique_external_id": "2-index", + }, + ] + + # Check that the DB is set up correctly after a rewrite of a new file + # 1 chunk has 1, another chunk has 1, another chunk has 1 + report.ingest(sample_bundle_stats_path_8) + + query = ( + select(Chunk.unique_external_id, Asset.name) + .join(DynamicImport, Chunk.id == DynamicImport.chunk_id) + .join(Asset, Asset.id == DynamicImport.asset_id) + .order_by(Chunk.unique_external_id) + ) + + results = db_session.execute(query).all() + + result_dicts = [ + {"unique_external_id": unique_external_id, "asset_name": asset_name} + for unique_external_id, asset_name in results + ] + + assert result_dicts == [ + { + "asset_name": "LazyComponent-BBSC53Nv.js", + "unique_external_id": "0-index", + }, + { + "asset_name": "index-C-Z8zsvD.js", + "unique_external_id": "1-LazyComponent", + }, + { + "asset_name": "index-C-Z8zsvD.js", + "unique_external_id": "2-index", + }, + ] + + finally: + report.cleanup()