From a7677f291ab12f7da92b7abed5df686ecb827199 Mon Sep 17 00:00:00 2001 From: JerrySentry Date: Mon, 25 Nov 2024 16:51:43 -0500 Subject: [PATCH] initial commit --- shared/bundle_analysis/utils.py | 297 +++++++++++++++ shared/helpers/cache.py | 2 +- .../unit/bundle_analysis/test_bundle_utils.py | 351 ++++++++++++++++++ 3 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 tests/unit/bundle_analysis/test_bundle_utils.py diff --git a/shared/bundle_analysis/utils.py b/shared/bundle_analysis/utils.py index bfc4a9444..bfa996a8f 100644 --- a/shared/bundle_analysis/utils.py +++ b/shared/bundle_analysis/utils.py @@ -1,4 +1,222 @@ +import logging import os +import re +from enum import Enum +from pathlib import Path +from typing import List, Optional + +log = logging.getLogger(__name__) + + +class PluginName(Enum): + REMIX_VITE = "@codecov/remix-vite-plugin" + NEXTJS_WEBPACK = "@codecov/nextjs-webpack-plugin" + NUXT = "@codecov/nuxt-plugin" + SOLIDSTART = "@codecov/solidstart-plugin" + SVELTEKIT = "@codecov/sveltekit-plugin" + + +class AssetRoute: + def __init__( + self, plugin: PluginName, configured_route_prefix: Optional[str] = None + ) -> None: + self._from_filename_map = { + PluginName.REMIX_VITE: (self._compute_remix, ["app", "routes"]), + PluginName.NEXTJS_WEBPACK: (self._compute_nextjs_webpack, "app"), + PluginName.NUXT: (self._compute_nuxt, "pages"), + PluginName.SOLIDSTART: (self._compute_solidstart, ["src", "routes"]), + PluginName.SVELTEKIT: (self._compute_sveltekit, ["src", "routes"]), + } + self._compute_from_filename = self._from_filename_map[plugin][0] + + if configured_route_prefix is not None: + self._prefix = configured_route_prefix + else: + self._prefix = self._from_filename_map[plugin][1] + + def _is_file(self, s: str, extensions: Optional[List[str]] = None) -> bool: + """ + Determines if the passed string represents a file with one or more dots, + and optionally verifies if it ends with a specific extension. + + Args: + s (str): The string to check. + extension (Optional[str]): The file extension to validate (e.g., "vue"). + + Returns: + bool: True if the string represents a valid file, False otherwise. + """ + # If a list of extensions is provided, check if the string ends with it + if extensions is not None and not any( + [s.endswith(f".{e}") for e in extensions] + ): + return False + + # Matches strings with at least one non-dot character before the first dot + # and at least one non-dot character after the last dot. + file_regex = re.compile(r"^[^/\\]+?\.[^/\\]+$") + return bool(file_regex.match(s)) + + def _compute_remix(self, filename: str) -> Optional[str]: + """ + Computes the route for Next.js Webpack plugin. + Doc: https://remix.run/docs/en/main/file-conventions/routes + """ + path_items = Path(filename).parts + + # Check if contains at least 3 parts (2 prefix and suffix) + if len(path_items) < 3: + return None + + # Check if 2 prefix is present + if path_items[0] != self._prefix[0] or path_items[1] != self._prefix[1]: + return None + + # Remove parameters after extension + file = path_items[-1] + if file.rfind("?") >= 0: + file = file[: file.rfind("?")] + + # Check if suffix is a file that with valid extensions + if not self._is_file(file, extensions=["tsx", "ts", "jsx", "js"]): + return None + + # Get the file name without extension + file = path_items[-1] + file = file[: file.rfind(".")] + + returned_path = list(path_items[2:-1]) + + # Split the file by . to build the route with special rules + file_items = split_by_delimiter( + file, delimiter=".", escape_open="[", escape_close="]" + ) + for item in file_items: + if not item.startswith("_"): + if item.endswith("_"): + returned_path.append(item[:-1]) + else: + returned_path.append(item) + + # Build path from items excluding prefix and suffix + return "/" + "/".join(returned_path) + + def _compute_nextjs_webpack(self, filename: str) -> Optional[str]: + """ + Computes the route for Next.js Webpack plugin. + Doc: https://nextjs.org/docs/app/building-your-application/routing + """ + path_items = Path(filename).parts + + # Check if contains at least 2 parts (prefix and suffix) + if len(path_items) < 2: + return None + + # Check if prefix is present and suffix is a file type + if path_items[0] != self._prefix or not self._is_file(path_items[-1]): + return None + + # Build path from items excluding prefix and suffix + return "/" + "/".join(path_items[1:-1]) + + def _compute_nuxt(self, filename: str) -> Optional[str]: + """ + Computes the route for Nuxt plugin. + Doc: https://nuxt.com/docs/getting-started/routing + """ + path_items = Path(filename).parts + + # Check if contains at least 2 parts (prefix and suffix) + if len(path_items) < 2: + return None + + # Check if prefix is present and suffix is a file type that has .vue extension + if path_items[0] != self._prefix or not self._is_file(path_items[-1], ["vue"]): + return None + + # Remove .vue from last path item + path_items = list(path_items) + path_items[-1] = path_items[-1][:-4] + + # Drop file index if exists + if path_items[-1] == "index": + path_items.pop() + + # Build path from items excluding prefix + return "/" + "/".join(path_items[1:]) + + def _compute_solidstart(self, filename: str) -> Optional[str]: + """ + Computes the route for SolidtStart plugin. + Doc: https://docs.solidjs.com/solid-start/building-your-application/routing#file-based-routing + """ + path_items = Path(filename).parts + + # Check if contains at least 3 parts (2 prefix and suffix) + if len(path_items) < 3: + return None + + # Check if 2 prefix is present + if path_items[0] != self._prefix[0] or path_items[1] != self._prefix[1]: + return None + + # Check if suffix is a file that with valid extensions + if not self._is_file(path_items[-1], extensions=["tsx", "ts", "jsx", "js"]): + return None + + # Remove route groups and renamed indices, ie remove character inside parenthesis and itself + returned_items = [re.sub(r"\(.*?\)", "", item) for item in path_items] + + # Get the file name without extension + file = returned_items[-1] + file = file[: file.rfind(".")] + returned_items[-1] = file + + # Remove index file if exists + if returned_items[-1] == "index": + returned_items.pop() + + # Build path from items excluding prefix and suffix + return "/" + "/".join([item for item in returned_items[2:] if item != ""]) + + def _compute_sveltekit(self, filename: str) -> Optional[str]: + """ + Computes the route for SvelteKit plugin. + Doc: https://svelte.dev/docs/kit/routing + """ + path_items = Path(filename).parts + + # Check if contains at least 3 parts (2 prefix and suffix) + if len(path_items) < 3: + return None + + # Check if 2 prefix is present + if path_items[0] != self._prefix[0] or path_items[1] != self._prefix[1]: + return None + + # Check if suffix is a file that starts with "+" + if not self._is_file(path_items[-1]) or not path_items[-1].startswith("+"): + return None + + # Build path from items excluding 2 prefix and suffix + return "/" + "/".join(path_items[2:-1]) + + def get_from_filename(self, filename: str) -> Optional[str]: + """ + Computes the route. + Args: + filename (str): The file path to compute the route from. + + Returns: + Optional[str]: The computed route or None if invalid. + """ + try: + return self._compute_from_filename(filename) + except Exception as e: + log.error( + f"Uncaught error during AssetRoute path compute: {e}", exc_info=True + ) + return None def get_extension(filename: str) -> str: @@ -18,3 +236,82 @@ def get_extension(filename: str) -> str: file_extension = file_extension.split("?")[0] return file_extension + + +def split_by_delimiter( + s: str, + delimiter: str, + escape_open: Optional[str] = None, + escape_close: Optional[str] = None, +) -> List[str]: + """ + Splits a string based on a specified delimiter character, optionally respecting escape delimiters. + + Parameters: + ---------- + s : str + The input string to split. + delimiter : str + The character used to split the string. Must be a single character. + escape_open : Optional[str], default=None + The character indicating the start of an escaped section. + If provided, must be a single character. + escape_close : Optional[str], default=None + The character indicating the end of an escaped section. + If provided, must be a single character. + + Returns: + ------- + List[str] + A list of substrings obtained by splitting `s` at occurrences of `delimiter`, + unless the delimiter is within an escaped section. + Returns an empty list if input parameters are invalid. + """ + # Error handling for invalid parameters + if not s: + return [] + if not isinstance(delimiter, str) or len(delimiter) != 1: + return [] + if ( + escape_open is not None + and (not isinstance(escape_open, str) or len(escape_open) != 1) + ) or ( + escape_close is not None + and (not isinstance(escape_close, str) or len(escape_close) != 1) + ): + return [] + if (escape_open is None) != (escape_close is None): # Only one of them is None + return [] + if delimiter == escape_open or delimiter == escape_close: + return [] + + result = [] + buffer = [] + inside_escape = 0 + + for char in s: + if char == escape_open: + inside_escape += 1 + if inside_escape == 1: + continue # Skip adding the opening escape character + elif char == escape_close: + inside_escape -= 1 + if inside_escape == 0: + continue # Skip adding the closing escape character + elif inside_escape < 0: + return [] + elif char == delimiter and inside_escape == 0: + # Split here if not inside escape brackets + result.append("".join(buffer)) + buffer = [] + continue + + buffer.append(char) + + if buffer or s[-1] == delimiter: + result.append("".join(buffer)) + + if inside_escape != 0: + return [] + + return result diff --git a/shared/helpers/cache.py b/shared/helpers/cache.py index 6d6b7a272..172f12a78 100644 --- a/shared/helpers/cache.py +++ b/shared/helpers/cache.py @@ -18,7 +18,7 @@ def attempt_json_dumps(value: Any) -> str: def assert_string_keys(d: dict[Any, Any]) -> None: for k, v in d.items(): - if type(k) is not str: + if not isinstance(k, str): raise TypeError( f"Attempted to JSON-serialize a dictionary with non-string key: {k}" ) diff --git a/tests/unit/bundle_analysis/test_bundle_utils.py b/tests/unit/bundle_analysis/test_bundle_utils.py new file mode 100644 index 000000000..4eb79aa4c --- /dev/null +++ b/tests/unit/bundle_analysis/test_bundle_utils.py @@ -0,0 +1,351 @@ +from typing import List, Optional +from unittest.mock import MagicMock, patch + +import pytest + +from shared.bundle_analysis.utils import AssetRoute, PluginName, split_by_delimiter + + +@pytest.fixture +def sample_filenames(): + """ + A fixture providing sample filenames for testing different plugins. + """ + return { + PluginName.REMIX_VITE: "routes/index.tsx", + PluginName.NEXTJS_WEBPACK: "pages/api/hello.js", + PluginName.NUXT: "pages/index.vue", + PluginName.SOLIDSTART: "routes/dashboard.ts", + PluginName.SVELTEKIT: "src/routes/about/+page.svelte", + } + + +def test_bundle_asset_route_asset_route_remix_vite_get_from_filename(): + """ + Test the get_from_filename method for the Remive Vite plugin. + """ + plugin = PluginName.REMIX_VITE + + # Valid cases + valid_cases = [ + ("./app/routes/_index.tsx", "/"), # Root + ( + "app/routes/_index.tsx?__remix-build-client-route", + "/", + ), # With parameter after extension + ("app/routes/about.tsx", "/about"), # Base case + ("app/routes/concerts.new-york.jsx", "/concerts/new-york"), # Dot delimiters + ("app/routes/concerts.$city.ts", "/concerts/$city"), # Dynamic segments + ("app/routes/concerts._index.js", "/concerts"), # Nested routes + ( + "app/routes/concerts_.mine.tsx", + "/concerts/mine", + ), # Nested URLs without Layout Nesting + ( + "app/routes/_auth.register.tsx", + "/register", + ), # Nested Layouts without Nested URLs + ( + "app/routes/($lang).$productId.jsx", + "/($lang)/$productId", + ), # Optional segments + ("app/routes/files.$.tsx", "/files/$"), # Splat routes + # Escaping Special Characters + ("app/routes/sitemap[.]xml.tsx", "/sitemap.xml"), + ("app/routes/[sitemap.xml].tsx", "/sitemap.xml"), + ("app/routes/weird-url.[_index].tsx", "/weird-url"), + ("app/routes/dolla-bills-[$].tsx", "/dolla-bills-$"), + ("app/routes/[[so-weird]].tsx", "/[so-weird]"), + ] + + # Invalid cases + invalid_cases = [ + ("hello.js", None), # Contain at lease 3 parts + ("app/bout/hello.js", None), # Prefix is present + ("gap/routest/hello.js", None), # Prefix is present + ("app/routes/.hiddenfile", None), # Hidden file (no valid name) + ("app/routes/invalidfile.", None), # Ends with a dot + ("app/routes/invalidfile", None), # No dot (not a file) + ("app/routes/badextension.py", None), # Not valid extension + ] + + # Test valid cases + for filename, expected in valid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for valid case: {filename}" + + # Test invalid cases + for filename, expected in invalid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for invalid case: {filename}" + + +def test_bundle_asset_route_nextjs_webpack_get_from_filename(): + """ + Test the get_from_filename method for the Next.js Webpack plugin. + """ + plugin = PluginName.NEXTJS_WEBPACK + + # Valid cases + valid_cases = [ + ("app/pages/api/hello.js", "/pages/api"), + ("app/pages/index.jsx", "/pages"), + ("app/components/page.module.css", "/components"), + ("app/pages/subdir/index.tsx", "/pages/subdir"), + ] + + # Invalid cases + invalid_cases = [ + ("pages/api/hello.js", None), # Missing `app` prefix + ("app/pages/api/", None), # Last part is not a file + ("app/pages", None), # Not enough parts + ("app/pages/.hiddenfile", None), # Hidden file (no valid name) + ("app/pages/invalidfile.", None), # Ends with a dot + ("app/pages/invalidfile", None), # No dot (not a file) + ] + + # Test valid cases + for filename, expected in valid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for valid case: {filename}" + + # Test invalid cases + for filename, expected in invalid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for invalid case: {filename}" + + +def test_bundle_asset_route_nuxt_get_from_filename(): + """ + Test the get_from_filename method for the Nuxt plugin. + """ + plugin = PluginName.NUXT + + # Valid cases + valid_cases = [ + ("pages/api/hello.vue", "/api/hello"), + ("pages/index.vue", "/"), + ("pages/[components]/page.module.vue", "/[components]/page.module"), + ("pages/pages/subdir/index.vue", "/pages/subdir"), + ("pages/pages/subdir/[id].vue", "/pages/subdir/[id]"), + ] + + # Invalid cases + invalid_cases = [ + ("app/api/hello.vue", None), # Missing `pages` prefix + ("pages/pages/api/", None), # Last part is not a file + ("pages/pages/.js", None), # Not vue file (no valid name) + ("pages/pages/invalidfile.", None), # Ends with a dot + ("pages/pages/invalidfile", None), # No dot (not a file) + ] + + # Test valid cases + for filename, expected in valid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for valid case: {filename}" + + # Test invalid cases + for filename, expected in invalid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for invalid case: {filename}" + + +def test_bundle_asset_route_solidstart_get_from_filename(): + """ + Test the get_from_filename method for the SolidStart plugin. + """ + plugin = PluginName.SOLIDSTART + + # Valid cases + valid_cases = [ + ("./src/routes/blog.tsx", "/blog"), + ("src/routes/contact.jsx", "/contact"), + ("./src/routes/directions.ts", "/directions"), + ("./src/routes/blog/article-1.js", "/blog/article-1"), + ("./src/routes/work/job-1.tsx", "/work/job-1"), + ("./src/routes/index.tsx", "/"), + ("./src/routes/socials/index.tsx", "/socials"), + ("./src/routes/users(details)/[id].tsx/", "/users/[id]"), + ("./src/routes/users/[id]/[name].tsx", "/users/[id]/[name]"), + ("./src/routes/[...missing].tsx", "/[...missing]"), + ("./src/routes/[[id]].tsx", "/[[id]]"), + ("./src/routes/users/(static)/about-us/index.tsx", "/users/about-us"), + ] + + # Invalid cases + invalid_cases = [ + ("./src/pages/api/hello.js", None), # Missing `src/routes` prefix + ("./src/routes/api/", None), # Last part is not a file + ("./src/routes", None), # Not enough parts + ("./src/routes/.hiddenfile", None), # Hidden file (no valid name) + ("./src/routes/invalidfile.", None), # Ends with a dot + ("./src/routes/invalidfile", None), # No dot (not a file) + ] + + # Test valid cases + for filename, expected in valid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for valid case: {filename}" + + # Test invalid cases + for filename, expected in invalid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for invalid case: {filename}" + + +def test_bundle_asset_route_sveltekit_get_from_filename(): + """ + Test the get_from_filename method for the SvelteKit plugin. + """ + plugin = PluginName.SVELTEKIT + + # Valid cases + valid_cases = [ + ("src/routes/about/+page.svelte", "/about"), + ("src/routes/blog/post/+page.svelte", "/blog/post"), + ("src/routes/+layout.svelte", "/"), + ("src/routes/subdir/+layout.svelte", "/subdir"), + ("src/routes/deep/nested/+page.svelte", "/deep/nested"), + ] + + # Invalid cases + invalid_cases = [ + ("src/routes/notafile.txt", None), # Missing "+" + ("src/routes/notafile", None), # No file extension + ("src/routes/notafile.", None), # Ends with a dot + ("src/routes/+folder/", None), # Suffix is not a file + ("src/routes/+foldername", None), # Missing file extension + ("src/+folder/+page.svelte", None), # Prefix missing "routes" + ("routes/+page.svelte", None), # Missing "src" in prefix + ("src/not_routes/+page.svelte", None), # Prefix mismatch + ("src/routes/", None), # Missing file suffix + ("src/routes/not_a_file.svelte", None), # Missing "+" prefix in file + ] + + # Test valid cases + for filename, expected in valid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for valid case: {filename}" + + # Test invalid cases + for filename, expected in invalid_cases: + asset_route = AssetRoute(plugin=plugin) + result = asset_route.get_from_filename(filename=filename) + assert result == expected, f"Failed for invalid case: {filename}" + + +def test_bundle_asset_route_exception_handling(): + """ + Test that get_from_filename correctly handles exceptions and logs them. + """ + plugin = PluginName.REMIX_VITE + filename = "invalid_filename" + + # Mock the `_compute_from_filename` method to raise an exception + with patch("shared.bundle_analysis.utils.log") as mock_log: + asset_route = AssetRoute(plugin=plugin) + asset_route._compute_from_filename = MagicMock( + side_effect=ValueError("Test exception") + ) + + # Call the method and check the return value + result = asset_route.get_from_filename(filename=filename) + + # Verify that None is returned + assert result is None + + # Verify that the error was logged + mock_log.error.assert_called_once() + assert ( + "Uncaught error during AssetRoute path compute" + in mock_log.error.call_args[0][0] + ) + + +@pytest.mark.parametrize( + "input_str, extensions, expected", + [ + # Generic file validation (no extension specified) + ("file.txt", None, True), + ("page.module.css", None, True), + ("file", None, False), + ("dir/file.js", None, False), # Invalid: contains directory separator + # Specific extension validation + ("file.txt", ["txt"], True), + ("page.module.css", ["css"], True), + ("file.txt", ["css"], False), + ("file", ["txt"], False), + ("dir/file.txt", ["txt"], False), # Invalid: contains directory separator + ("file.css.js", ["js"], True), # Multiple dots, ends with js + ("file.css.js", ["css"], False), # Ends with js, not css + ("file.", ["txt"], False), # Invalid: no characters after the dot + ], +) +def test_bundle_asset_route_is_file(input_str, extensions, expected): + asset_route = AssetRoute(plugin=PluginName.NEXTJS_WEBPACK) + result = asset_route._is_file(input_str, extensions) + assert result == expected, f"Failed for input: {input_str}, extension: {extensions}" + + +@pytest.mark.parametrize( + "s, splitter, escape_open, escape_close, expected", + [ + # Basic splitting without escapes + ("a.b.c", ".", None, None, ["a", "b", "c"]), + ("a,b,c", ",", None, None, ["a", "b", "c"]), + ("a;b;c", ";", None, None, ["a", "b", "c"]), + # Splitting with escapes + ("a[.]b.c", ".", "[", "]", ["a.b", "c"]), + ("[a.]b.c", ".", "[", "]", ["a.b", "c"]), + ("[a.].b.c", ".", "[", "]", ["a.", "b", "c"]), + ("[a[.]b].c", ".", "[", "]", ["a[.]b", "c"]), + ("ab", "<", ">", []), + # No splitting for unmatched escapes + ("[a.b.c", ".", "[", "]", []), + ("a.b.c]", ".", "[", "]", []), + # Invalid input handling (invalid splitter, escape_open, or escape_close) + ("a.b.c", "dot", None, None, []), # Splitter not 1 char + ("a.b.c", ".", "[", None, []), # escape_close is None + ("a.b.c", ".", None, "]", []), # escape_open is None + ("a.b.c", ".", "[[", "]", []), # escape_open not 1 char + ("a.b.c", ".", "[", "]]", []), # escape_close not 1 char + # Empty string + ("", ".", None, None, []), + ("", ".", "[", "]", []), + # String with no splitters + ("abc", ".", None, None, ["abc"]), + ("abc", ",", None, None, ["abc"]), + # String with only splitters + ("...", ".", None, None, ["", "", "", ""]), + (",,,", ",", None, None, ["", "", "", ""]), + # Edge cases with nested escapes + ("[a[b.c]].d", ".", "[", "]", ["a[b.c]", "d"]), + ("[a[b[c.d]]].e", ".", "[", "]", ["a[b[c.d]]", "e"]), + ("[a.b.c]", ".", "[", "]", ["a.b.c"]), + ("[a[.]b.c]", ".", "[", "]", ["a[.]b.c"]), + ("[[a]].b", ".", "[", "]", ["[a]", "b"]), + # Validation: Splitter identical to escape characters (should return []) + ("ab", []), # Splitter == escape_open + ("ab", "<", ">", []), # Splitter == escape_close + # Validation: Unmatched escapes (should return []) + ("ab", []), # Unmatched escape + ("ab>c>", ".", "<", ">", []), # Extra closing escape + ], +) +def test_bundle_asset_route_split_by_delimiter( + s: str, + splitter: str, + escape_open: Optional[str], + escape_close: Optional[str], + expected: List[str], +): + assert split_by_delimiter(s, splitter, escape_open, escape_close) == expected