From d4aeb64af7ebda0c9884f4393b4f96b019e79935 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 20 Feb 2026 08:18:15 +0530 Subject: [PATCH 01/57] [WIP] react framework initial commit --- codeflash/languages/base.py | 1 + .../javascript/frameworks/__init__.py | 1 + .../javascript/frameworks/detector.py | 94 + .../javascript/frameworks/react/__init__.py | 1 + .../javascript/frameworks/react/analyzer.py | 161 ++ .../javascript/frameworks/react/context.py | 204 +++ .../javascript/frameworks/react/discovery.py | 251 +++ .../javascript/frameworks/react/profiler.py | 244 +++ .../javascript/frameworks/react/testgen.py | 120 ++ codeflash/languages/javascript/parse.py | 38 + codeflash/languages/javascript/support.py | 74 + .../languages/javascript/treesitter_utils.py | 1588 +++++++++++++++++ codeflash/models/function_types.py | 3 +- 13 files changed, 2779 insertions(+), 1 deletion(-) create mode 100644 codeflash/languages/javascript/frameworks/__init__.py create mode 100644 codeflash/languages/javascript/frameworks/detector.py create mode 100644 codeflash/languages/javascript/frameworks/react/__init__.py create mode 100644 codeflash/languages/javascript/frameworks/react/analyzer.py create mode 100644 codeflash/languages/javascript/frameworks/react/context.py create mode 100644 codeflash/languages/javascript/frameworks/react/discovery.py create mode 100644 codeflash/languages/javascript/frameworks/react/profiler.py create mode 100644 codeflash/languages/javascript/frameworks/react/testgen.py create mode 100644 codeflash/languages/javascript/treesitter_utils.py diff --git a/codeflash/languages/base.py b/codeflash/languages/base.py index 3e10da319..ce19c536b 100644 --- a/codeflash/languages/base.py +++ b/codeflash/languages/base.py @@ -93,6 +93,7 @@ class CodeContext: read_only_context: str = "" imports: list[str] = field(default_factory=list) language: Language = Language.PYTHON + react_context: str | None = None @dataclass diff --git a/codeflash/languages/javascript/frameworks/__init__.py b/codeflash/languages/javascript/frameworks/__init__.py new file mode 100644 index 000000000..c4bf7a8df --- /dev/null +++ b/codeflash/languages/javascript/frameworks/__init__.py @@ -0,0 +1 @@ +"""Framework detection and support for JavaScript/TypeScript projects.""" diff --git a/codeflash/languages/javascript/frameworks/detector.py b/codeflash/languages/javascript/frameworks/detector.py new file mode 100644 index 000000000..013de47f5 --- /dev/null +++ b/codeflash/languages/javascript/frameworks/detector.py @@ -0,0 +1,94 @@ +"""Framework detection for JavaScript/TypeScript projects. + +Detects React (and potentially other frameworks) by inspecting package.json +dependencies. Results are cached per project root. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class FrameworkInfo: + """Information about the frontend framework used in a project.""" + + name: str # "react", "vue", "angular", "none" + version: str | None = None # e.g., "18.2.0" + react_version_major: int | None = None # e.g., 18 + has_testing_library: bool = False # @testing-library/react installed + has_react_compiler: bool = False # React 19+ compiler detected + dev_dependencies: frozenset[str] = field(default_factory=frozenset) + + +_EMPTY_FRAMEWORK = FrameworkInfo(name="none") + + +@lru_cache(maxsize=32) +def detect_framework(project_root: Path) -> FrameworkInfo: + """Detect the frontend framework from package.json. + + Reads dependencies and devDependencies to identify React and its ecosystem. + Results are cached per project root path. + """ + package_json_path = project_root / "package.json" + if not package_json_path.exists(): + return _EMPTY_FRAMEWORK + + try: + package_data = json.loads(package_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + logger.debug("Failed to read package.json at %s: %s", package_json_path, e) + return _EMPTY_FRAMEWORK + + deps = package_data.get("dependencies", {}) + dev_deps = package_data.get("devDependencies", {}) + all_deps = {**deps, **dev_deps} + + # Detect React + react_version_str = deps.get("react") or dev_deps.get("react") + if not react_version_str: + return _EMPTY_FRAMEWORK + + version = _parse_version_string(react_version_str) + major = _parse_major_version(version) + + has_testing_library = "@testing-library/react" in all_deps + has_react_compiler = ( + "babel-plugin-react-compiler" in all_deps + or "react-compiler-runtime" in all_deps + or (major is not None and major >= 19) + ) + + return FrameworkInfo( + name="react", + version=version, + react_version_major=major, + has_testing_library=has_testing_library, + has_react_compiler=has_react_compiler, + dev_dependencies=frozenset(all_deps.keys()), + ) + + +def _parse_version_string(version_spec: str) -> str | None: + """Extract a clean version from a semver range like ^18.2.0 or ~17.0.0.""" + stripped = version_spec.lstrip("^~>= int | None: + """Extract major version number from a version string.""" + if not version: + return None + try: + return int(version.split(".")[0]) + except (ValueError, IndexError): + return None diff --git a/codeflash/languages/javascript/frameworks/react/__init__.py b/codeflash/languages/javascript/frameworks/react/__init__.py new file mode 100644 index 000000000..c7622b0d6 --- /dev/null +++ b/codeflash/languages/javascript/frameworks/react/__init__.py @@ -0,0 +1 @@ +"""React framework support for component discovery, profiling, and optimization.""" diff --git a/codeflash/languages/javascript/frameworks/react/analyzer.py b/codeflash/languages/javascript/frameworks/react/analyzer.py new file mode 100644 index 000000000..db87c22e6 --- /dev/null +++ b/codeflash/languages/javascript/frameworks/react/analyzer.py @@ -0,0 +1,161 @@ +"""Static analysis for React optimization opportunities. + +Detects common performance anti-patterns in React components: +- Inline object/array creation in JSX props +- Functions defined inside render body (missing useCallback) +- Expensive computations without useMemo +- Components receiving referentially unstable props +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from codeflash.languages.javascript.frameworks.react.discovery import ReactComponentInfo + + +class OpportunitySeverity(str, Enum): + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +class OpportunityType(str, Enum): + INLINE_OBJECT_PROP = "inline_object_prop" + INLINE_ARRAY_PROP = "inline_array_prop" + MISSING_USECALLBACK = "missing_usecallback" + MISSING_USEMEMO = "missing_usememo" + MISSING_REACT_MEMO = "missing_react_memo" + UNSTABLE_REFERENCE = "unstable_reference" + + +@dataclass(frozen=True) +class OptimizationOpportunity: + """A detected optimization opportunity in a React component.""" + + type: OpportunityType + line: int + description: str + severity: OpportunitySeverity + + +# Patterns for expensive operations inside render body +EXPENSIVE_OPS_RE = re.compile( + r"\.(filter|map|sort|reduce|flatMap|find|findIndex|every|some)\s*\(" +) +INLINE_OBJECT_IN_JSX_RE = re.compile(r"=\{\s*\{") # ={{ ... }} in JSX +INLINE_ARRAY_IN_JSX_RE = re.compile(r"=\{\s*\[") # ={[ ... ]} in JSX +FUNCTION_DEF_RE = re.compile( + r"(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>" + r"|function\s+\w+\s*\(" +) +USECALLBACK_RE = re.compile(r"\buseCallback\s*\(") +USEMEMO_RE = re.compile(r"\buseMemo\s*\(") + + +def detect_optimization_opportunities( + source: str, component_info: ReactComponentInfo +) -> list[OptimizationOpportunity]: + """Detect optimization opportunities in a React component.""" + opportunities: list[OptimizationOpportunity] = [] + lines = source.splitlines() + + # Only analyze the component's own lines + start = component_info.start_line - 1 + end = min(component_info.end_line, len(lines)) + component_lines = lines[start:end] + component_source = "\n".join(component_lines) + + # Check for inline objects in JSX props + _detect_inline_props(component_lines, start, opportunities) + + # Check for functions defined in render body without useCallback + _detect_missing_usecallback(component_source, component_lines, start, opportunities) + + # Check for expensive computations without useMemo + _detect_missing_usememo(component_source, component_lines, start, opportunities) + + # Check if component should be wrapped in React.memo + if not component_info.is_memoized: + opportunities.append(OptimizationOpportunity( + type=OpportunityType.MISSING_REACT_MEMO, + line=component_info.start_line, + description=f"Component '{component_info.function_name}' is not wrapped in React.memo(). " + "If it receives stable props, wrapping can prevent unnecessary re-renders.", + severity=OpportunitySeverity.MEDIUM, + )) + + return opportunities + + +def _detect_inline_props( + lines: list[str], offset: int, opportunities: list[OptimizationOpportunity] +) -> None: + """Detect inline object/array literals in JSX prop positions.""" + for i, line in enumerate(lines): + line_num = offset + i + 1 + if INLINE_OBJECT_IN_JSX_RE.search(line): + opportunities.append(OptimizationOpportunity( + type=OpportunityType.INLINE_OBJECT_PROP, + line=line_num, + description="Inline object literal in JSX prop creates a new reference on every render. " + "Extract to useMemo or a module-level constant.", + severity=OpportunitySeverity.HIGH, + )) + if INLINE_ARRAY_IN_JSX_RE.search(line): + opportunities.append(OptimizationOpportunity( + type=OpportunityType.INLINE_ARRAY_PROP, + line=line_num, + description="Inline array literal in JSX prop creates a new reference on every render. " + "Extract to useMemo or a module-level constant.", + severity=OpportunitySeverity.HIGH, + )) + + +def _detect_missing_usecallback( + component_source: str, + lines: list[str], + offset: int, + opportunities: list[OptimizationOpportunity], +) -> None: + """Detect arrow functions or function expressions that could use useCallback.""" + has_usecallback = bool(USECALLBACK_RE.search(component_source)) + + for i, line in enumerate(lines): + line_num = offset + i + 1 + stripped = line.strip() + # Look for arrow function or function expression definitions inside the component + if FUNCTION_DEF_RE.search(stripped) and "useCallback" not in stripped and "useMemo" not in stripped: + # Skip if the component already uses useCallback extensively + if not has_usecallback: + opportunities.append(OptimizationOpportunity( + type=OpportunityType.MISSING_USECALLBACK, + line=line_num, + description="Function defined inside render body creates a new reference on every render. " + "Wrap with useCallback() if passed as a prop to child components.", + severity=OpportunitySeverity.MEDIUM, + )) + + +def _detect_missing_usememo( + component_source: str, + lines: list[str], + offset: int, + opportunities: list[OptimizationOpportunity], +) -> None: + """Detect expensive computations that could benefit from useMemo.""" + for i, line in enumerate(lines): + line_num = offset + i + 1 + stripped = line.strip() + if EXPENSIVE_OPS_RE.search(stripped) and "useMemo" not in stripped: + opportunities.append(OptimizationOpportunity( + type=OpportunityType.MISSING_USEMEMO, + line=line_num, + description="Expensive array operation in render body runs on every render. " + "Wrap with useMemo() and specify dependencies.", + severity=OpportunitySeverity.HIGH, + )) diff --git a/codeflash/languages/javascript/frameworks/react/context.py b/codeflash/languages/javascript/frameworks/react/context.py new file mode 100644 index 000000000..b5dc2b871 --- /dev/null +++ b/codeflash/languages/javascript/frameworks/react/context.py @@ -0,0 +1,204 @@ +"""React-specific context extraction for component optimization. + +Extracts props interfaces, hook usage, parent/child component relationships, +context subscriptions, and optimization opportunities from React components. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from codeflash.languages.javascript.frameworks.react.analyzer import OptimizationOpportunity + from codeflash.languages.javascript.frameworks.react.discovery import ReactComponentInfo + from codeflash.languages.javascript.treesitter import TreeSitterAnalyzer + +logger = logging.getLogger(__name__) + + +@dataclass +class HookUsage: + """Represents a hook call within a component.""" + + name: str + has_dependency_array: bool = False + dependency_count: int = 0 + + +@dataclass +class ReactContext: + """Context information for a React component, used in LLM prompts.""" + + props_interface: str | None = None + hooks_used: list[HookUsage] = field(default_factory=list) + parent_usages: list[str] = field(default_factory=list) + child_components: list[str] = field(default_factory=list) + context_subscriptions: list[str] = field(default_factory=list) + is_already_memoized: bool = False + optimization_opportunities: list[OptimizationOpportunity] = field(default_factory=list) + + def to_prompt_string(self) -> str: + """Format this context for inclusion in an LLM optimization prompt.""" + parts: list[str] = [] + + if self.props_interface: + parts.append(f"Props interface:\n```typescript\n{self.props_interface}\n```") + + if self.hooks_used: + hook_lines = [] + for hook in self.hooks_used: + dep_info = f" (deps: {hook.dependency_count})" if hook.has_dependency_array else " (no deps)" + hook_lines.append(f" - {hook.name}{dep_info}") + parts.append("Hooks used:\n" + "\n".join(hook_lines)) + + if self.child_components: + parts.append("Child components rendered: " + ", ".join(self.child_components)) + + if self.context_subscriptions: + parts.append("Context subscriptions: " + ", ".join(self.context_subscriptions)) + + if self.is_already_memoized: + parts.append("Note: Component is already wrapped in React.memo()") + + if self.optimization_opportunities: + opp_lines = [] + for opp in self.optimization_opportunities: + opp_lines.append(f" - [{opp.severity.value}] Line {opp.line}: {opp.description}") + parts.append("Detected optimization opportunities:\n" + "\n".join(opp_lines)) + + return "\n\n".join(parts) + + +def extract_react_context( + component_info: ReactComponentInfo, + source: str, + analyzer: TreeSitterAnalyzer, + module_root: Path, +) -> ReactContext: + """Extract React-specific context for a component. + + Analyzes the component source to find props types, hooks, child components, + and optimization opportunities. + """ + from codeflash.languages.javascript.frameworks.react.analyzer import ( # noqa: PLC0415 + detect_optimization_opportunities, + ) + + context = ReactContext( + props_interface=component_info.props_type, + is_already_memoized=component_info.is_memoized, + ) + + # Extract hook usage details from the component source + lines = source.splitlines() + start = component_info.start_line - 1 + end = min(component_info.end_line, len(lines)) + component_source = "\n".join(lines[start:end]) + + context.hooks_used = _extract_hook_usages(component_source) + context.child_components = _extract_child_components(component_source, analyzer, source) + context.context_subscriptions = _extract_context_subscriptions(component_source) + context.optimization_opportunities = detect_optimization_opportunities(source, component_info) + + # Extract full props interface definition if we have a type name + if component_info.props_type: + full_interface = _find_type_definition(component_info.props_type, source, analyzer) + if full_interface: + context.props_interface = full_interface + + return context + + +def _extract_hook_usages(component_source: str) -> list[HookUsage]: + """Parse hook calls and their dependency arrays from component source.""" + import re # noqa: PLC0415 + + hooks: list[HookUsage] = [] + # Match useXxx( patterns + hook_pattern = re.compile(r"\b(use[A-Z]\w*)\s*\(") + + for match in hook_pattern.finditer(component_source): + hook_name = match.group(1) + # Try to determine if there's a dependency array + # Look for ], [ pattern after the hook call (simplified heuristic) + rest_of_line = component_source[match.end():] + has_deps = False + dep_count = 0 + + # Simple heuristic: count brackets to find dependency array + bracket_depth = 1 + for i, char in enumerate(rest_of_line): + if char == "(": + bracket_depth += 1 + elif char == ")": + bracket_depth -= 1 + if bracket_depth == 0: + # Check if the last argument before closing paren is an array + preceding = rest_of_line[:i].rstrip() + if preceding.endswith("]"): + has_deps = True + # Count items in the array (rough: count commas + 1 for non-empty) + array_start = preceding.rfind("[") + if array_start >= 0: + array_content = preceding[array_start + 1:-1].strip() + if array_content: + dep_count = array_content.count(",") + 1 + else: + dep_count = 0 # empty deps [] + has_deps = True + break + + hooks.append(HookUsage( + name=hook_name, + has_dependency_array=has_deps, + dependency_count=dep_count, + )) + + return hooks + + +def _extract_child_components(component_source: str, analyzer: TreeSitterAnalyzer, full_source: str) -> list[str]: + """Find child component names rendered in JSX.""" + import re # noqa: PLC0415 + + # Match JSX tags that start with uppercase (React components) + jsx_component_re = re.compile(r"<([A-Z][a-zA-Z0-9.]*)") + children = set() + for match in jsx_component_re.finditer(component_source): + name = match.group(1) + # Skip React built-ins like React.Fragment + if name not in ("React.Fragment", "Fragment", "Suspense", "React.Suspense"): + children.add(name) + return sorted(children) + + +def _extract_context_subscriptions(component_source: str) -> list[str]: + """Find React context subscriptions via useContext calls.""" + import re # noqa: PLC0415 + + context_re = re.compile(r"\buseContext\s*\(\s*(\w+)") + return [match.group(1) for match in context_re.finditer(component_source)] + + +def _find_type_definition(type_name: str, source: str, analyzer: TreeSitterAnalyzer) -> str | None: + """Find the full type/interface definition for a props type.""" + source_bytes = source.encode("utf-8") + tree = analyzer.parse(source_bytes) + + def search_node(node): + if node.type in ("interface_declaration", "type_alias_declaration"): + name_node = node.child_by_field_name("name") + if name_node: + name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8") + if name == type_name: + return source_bytes[node.start_byte:node.end_byte].decode("utf-8") + for child in node.children: + result = search_node(child) + if result: + return result + return None + + return search_node(tree.root_node) diff --git a/codeflash/languages/javascript/frameworks/react/discovery.py b/codeflash/languages/javascript/frameworks/react/discovery.py new file mode 100644 index 000000000..194088885 --- /dev/null +++ b/codeflash/languages/javascript/frameworks/react/discovery.py @@ -0,0 +1,251 @@ +"""React component discovery via tree-sitter analysis. + +Identifies React components (function, arrow, class) and hooks by analyzing +PascalCase naming, JSX returns, and hook usage patterns. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from codeflash.languages.javascript.treesitter import FunctionNode, TreeSitterAnalyzer + +logger = logging.getLogger(__name__) + +PASCAL_CASE_RE = re.compile(r"^[A-Z][a-zA-Z0-9]*$") +HOOK_CALL_RE = re.compile(r"\buse[A-Z]\w*\s*\(") +HOOK_NAME_RE = re.compile(r"^use[A-Z]\w*$") + +# Built-in React hooks +BUILTIN_HOOKS = frozenset({ + "useState", "useEffect", "useContext", "useReducer", "useCallback", + "useMemo", "useRef", "useImperativeHandle", "useLayoutEffect", + "useInsertionEffect", "useDebugValue", "useDeferredValue", + "useTransition", "useId", "useSyncExternalStore", "useOptimistic", + "useActionState", "useFormStatus", +}) + + +class ComponentType(str, Enum): + FUNCTION = "function" + ARROW = "arrow" + CLASS = "class" + HOOK = "hook" + + +@dataclass(frozen=True) +class ReactComponentInfo: + """Information about a discovered React component or hook.""" + + function_name: str + component_type: ComponentType + uses_hooks: tuple[str, ...] = () + returns_jsx: bool = False + props_type: str | None = None + is_memoized: bool = False + start_line: int = 0 + end_line: int = 0 + + +def is_react_component(func: FunctionNode, source: str, analyzer: TreeSitterAnalyzer) -> bool: + """Check if a function is a React component. + + A React component: + - Has a PascalCase name + - Returns JSX (or could be a hook if named use*) + - Is not a class method (standalone function) + """ + if func.is_method: + return False + + name = func.name + + # Hooks (useXxx) are not components + if HOOK_NAME_RE.match(name): + return False + + if not PASCAL_CASE_RE.match(name): + return False + + return _function_returns_jsx(func, source, analyzer) + + +def is_react_hook(func: FunctionNode) -> bool: + """Check if a function is a custom React hook (useXxx naming).""" + return bool(HOOK_NAME_RE.match(func.name)) and not func.is_method + + +def classify_component(func: FunctionNode, source: str, analyzer: TreeSitterAnalyzer) -> ComponentType | None: + """Classify a function as a React component type, hook, or None.""" + if is_react_hook(func): + return ComponentType.HOOK + + if not is_react_component(func, source, analyzer): + return None + + if func.is_arrow: + return ComponentType.ARROW + + return ComponentType.FUNCTION + + +def find_react_components(source: str, file_path: Path, analyzer: TreeSitterAnalyzer) -> list[ReactComponentInfo]: + """Find all React components and hooks in a source file. + + Skips files with "use server" directive (Next.js Server Components). + """ + # Skip Server Components + if _has_server_directive(source): + logger.debug("Skipping server component file: %s", file_path) + return [] + + functions = analyzer.find_functions( + source, include_methods=False, include_arrow_functions=True, require_name=True + ) + + components: list[ReactComponentInfo] = [] + for func in functions: + comp_type = classify_component(func, source, analyzer) + if comp_type is None: + continue + + hooks_used = _extract_hooks_used(func.source_text) + props_type = _extract_props_type(func, source, analyzer) + is_memoized = _is_wrapped_in_memo(func, source) + + components.append(ReactComponentInfo( + function_name=func.name, + component_type=comp_type, + uses_hooks=tuple(hooks_used), + returns_jsx=comp_type != ComponentType.HOOK and _function_returns_jsx(func, source, analyzer), + props_type=props_type, + is_memoized=is_memoized, + start_line=func.start_line, + end_line=func.end_line, + )) + + return components + + +def _has_server_directive(source: str) -> bool: + """Check for 'use server' directive at the top of the file.""" + for line in source.splitlines()[:5]: + stripped = line.strip() + if stripped in ('"use server"', "'use server'", '"use server";', "'use server';"): + return True + if stripped and not stripped.startswith("//") and not stripped.startswith("/*"): + break + return False + + +def _function_returns_jsx(func: FunctionNode, source: str, analyzer: TreeSitterAnalyzer) -> bool: + """Check if a function returns JSX by looking for jsx_element/jsx_self_closing_element nodes.""" + source_bytes = source.encode("utf-8") + node = func.node + + # For arrow functions with expression body (implicit return), check the body directly + body = node.child_by_field_name("body") + if body: + return _node_contains_jsx(body) + + return False + + +def _node_contains_jsx(node) -> bool: + """Recursively check if a tree-sitter node contains JSX.""" + if node.type in ( + "jsx_element", "jsx_self_closing_element", "jsx_fragment", + "jsx_expression", "jsx_opening_element", + ): + return True + + # Check return statements + if node.type == "return_statement": + for child in node.children: + if _node_contains_jsx(child): + return True + + for child in node.children: + if _node_contains_jsx(child): + return True + + return False + + +def _extract_hooks_used(function_source: str) -> list[str]: + """Extract hook names called within a function body.""" + hooks = [] + seen = set() + for match in HOOK_CALL_RE.finditer(function_source): + hook_name = match.group(0).rstrip("( \t") + if hook_name not in seen: + seen.add(hook_name) + hooks.append(hook_name) + return hooks + + +def _extract_props_type(func: FunctionNode, source: str, analyzer: TreeSitterAnalyzer) -> str | None: + """Extract the TypeScript props type annotation from a component's parameters.""" + source_bytes = source.encode("utf-8") + node = func.node + + # Look for formal_parameters -> type_annotation + params = node.child_by_field_name("parameters") + if not params: + return None + + for param in params.children: + # Look for type annotation on first parameter + if param.type in ("required_parameter", "optional_parameter"): + type_node = param.child_by_field_name("type") + if type_node: + # Get the type annotation node (skip the colon) + for child in type_node.children: + if child.type != ":": + return source_bytes[child.start_byte:child.end_byte].decode("utf-8") + # Destructured params with type: { foo, bar }: Props + if param.type == "object_pattern": + # Look for next sibling that is a type_annotation + next_sib = param.next_named_sibling + if next_sib and next_sib.type == "type_annotation": + for child in next_sib.children: + if child.type != ":": + return source_bytes[child.start_byte:child.end_byte].decode("utf-8") + + return None + + +def _is_wrapped_in_memo(func: FunctionNode, source: str) -> bool: + """Check if the component is already wrapped in React.memo or memo().""" + # Check if the variable declaration wrapping this function uses memo() + # e.g., const MyComp = React.memo(function MyComp(...) {...}) + # or const MyComp = memo((...) => {...}) + node = func.node + parent = node.parent + + while parent: + if parent.type == "call_expression": + func_node = parent.child_by_field_name("function") + if func_node: + source_bytes = source.encode("utf-8") + func_text = source_bytes[func_node.start_byte:func_node.end_byte].decode("utf-8") + if func_text in ("React.memo", "memo"): + return True + parent = parent.parent + + # Also check for memo wrapping at the export level: + # export default memo(MyComponent) + name = func.name + memo_patterns = [ + f"React.memo({name})", + f"memo({name})", + f"React.memo({name},", + f"memo({name},", + ] + return any(pattern in source for pattern in memo_patterns) diff --git a/codeflash/languages/javascript/frameworks/react/profiler.py b/codeflash/languages/javascript/frameworks/react/profiler.py new file mode 100644 index 000000000..9d273b70b --- /dev/null +++ b/codeflash/languages/javascript/frameworks/react/profiler.py @@ -0,0 +1,244 @@ +"""React Profiler instrumentation for render counting and timing. + +Wraps React components with React.Profiler to capture render count, +phase (mount/update), actualDuration, and baseDuration. Outputs structured +markers parseable by the existing marker-parsing infrastructure. + +Marker format: + !######REACT_RENDER:{component}:{phase}:{actualDuration}:{baseDuration}:{count}######! +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from codeflash.languages.javascript.treesitter import TreeSitterAnalyzer + +logger = logging.getLogger(__name__) + +MARKER_PREFIX = "REACT_RENDER" + + +def generate_render_counter_code(component_name: str) -> str: + """Generate the onRender callback and counter variable for Profiler instrumentation.""" + safe_name = re.sub(r"[^a-zA-Z0-9_]", "_", component_name) + return f"""\ +let _codeflash_render_count_{safe_name} = 0; +function _codeflashOnRender_{safe_name}(id, phase, actualDuration, baseDuration) {{ + _codeflash_render_count_{safe_name}++; + console.log(`!######{MARKER_PREFIX}:${{id}}:${{phase}}:${{actualDuration}}:${{baseDuration}}:${{_codeflash_render_count_{safe_name}}}######!`); +}}""" + + +def instrument_component_with_profiler(source: str, component_name: str, analyzer: TreeSitterAnalyzer) -> str: + """Instrument a single component with React.Profiler. + + Wraps all JSX return statements with and adds the + onRender callback + counter at module scope. + + Handles: + - Single return statements + - Conditional returns (if/else) + - Fragment returns (<>...) + - Early returns (leaves non-JSX returns alone) + """ + source_bytes = source.encode("utf-8") + tree = analyzer.parse(source_bytes) + + safe_name = re.sub(r"[^a-zA-Z0-9_]", "_", component_name) + profiler_id = component_name + + # Find the component function node + func_node = _find_component_function(tree.root_node, component_name, source_bytes) + if func_node is None: + logger.debug("Could not find component function: %s", component_name) + return source + + # Find all return statements with JSX inside this function + return_nodes = _find_jsx_returns(func_node, source_bytes) + if not return_nodes: + logger.debug("No JSX return statements found in: %s", component_name) + return source + + # Apply transformations in reverse order to preserve byte offsets + result = source + for ret_node in sorted(return_nodes, key=lambda n: n.start_byte, reverse=True): + result = _wrap_return_with_profiler(result, ret_node, profiler_id, safe_name) + + # Add render counter code at the top (after imports) + counter_code = generate_render_counter_code(component_name) + result = _insert_after_imports(result, counter_code, analyzer) + + # Ensure React is imported + result = _ensure_react_import(result) + + return result + + +def instrument_all_components_for_tracing(source: str, file_path: Path, analyzer: TreeSitterAnalyzer) -> str: + """Instrument ALL components in a file for tracing/discovery mode.""" + from codeflash.languages.javascript.frameworks.react.discovery import find_react_components # noqa: PLC0415 + + components = find_react_components(source, file_path, analyzer) + if not components: + return source + + result = source + # Process in reverse order by start_line to preserve positions + for comp in sorted(components, key=lambda c: c.start_line, reverse=True): + if comp.returns_jsx: + result = instrument_component_with_profiler(result, comp.function_name, analyzer) + + return result + + +def _find_component_function(root_node, component_name: str, source_bytes: bytes): + """Find the tree-sitter node for a named component function.""" + # Check function declarations + if root_node.type == "function_declaration": + name_node = root_node.child_by_field_name("name") + if name_node: + name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8") + if name == component_name: + return root_node + + # Check variable declarators with arrow functions (const MyComp = () => ...) + if root_node.type == "variable_declarator": + name_node = root_node.child_by_field_name("name") + if name_node: + name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8") + if name == component_name: + return root_node + + # Check export statements + if root_node.type in ("export_statement", "lexical_declaration", "variable_declaration"): + for child in root_node.children: + result = _find_component_function(child, component_name, source_bytes) + if result: + return result + + for child in root_node.children: + result = _find_component_function(child, component_name, source_bytes) + if result: + return result + + return None + + +def _find_jsx_returns(func_node, source_bytes: bytes) -> list: + """Find all return statements that contain JSX within a function node.""" + returns = [] + + def walk(node): + # Don't descend into nested functions + if node != func_node and node.type in ( + "function_declaration", "arrow_function", "function", "method_definition", + ): + return + + if node.type == "return_statement": + # Check if return value contains JSX + for child in node.children: + if _contains_jsx(child): + returns.append(node) + break + else: + for child in node.children: + walk(child) + + walk(func_node) + return returns + + +def _contains_jsx(node) -> bool: + """Check if a tree-sitter node contains JSX elements.""" + if node.type in ( + "jsx_element", "jsx_self_closing_element", "jsx_fragment", + ): + return True + for child in node.children: + if _contains_jsx(child): + return True + return False + + +def _wrap_return_with_profiler(source: str, return_node, profiler_id: str, safe_name: str) -> str: + """Wrap a return statement's JSX with React.Profiler.""" + source_bytes = source.encode("utf-8") + + # Find the JSX part of the return (skip "return" keyword and whitespace) + jsx_start = None + jsx_end = return_node.end_byte + + for child in return_node.children: + if child.type == "return": + continue + if child.type == ";": + jsx_end = child.start_byte + continue + if _contains_jsx(child): + jsx_start = child.start_byte + jsx_end = child.end_byte + break + + if jsx_start is None: + return source + + jsx_content = source_bytes[jsx_start:jsx_end].decode("utf-8").strip() + + # Check if the return uses parentheses: return (...) + # If so, we need to wrap inside the parens + has_parens = False + for child in return_node.children: + if child.type == "parenthesized_expression": + has_parens = True + jsx_start = child.start_byte + 1 # skip ( + jsx_end = child.end_byte - 1 # skip ) + jsx_content = source_bytes[jsx_start:jsx_end].decode("utf-8").strip() + break + + wrapped = ( + f'' + f"\n{jsx_content}\n" + f"" + ) + + return source[:jsx_start] + wrapped + source[jsx_end:] + + +def _insert_after_imports(source: str, code: str, analyzer: TreeSitterAnalyzer) -> str: + """Insert code after the last import statement.""" + source_bytes = source.encode("utf-8") + tree = analyzer.parse(source_bytes) + + last_import_end = 0 + for child in tree.root_node.children: + if child.type == "import_statement": + last_import_end = child.end_byte + + # Find end of line after last import + insert_pos = last_import_end + while insert_pos < len(source) and source[insert_pos] != "\n": + insert_pos += 1 + if insert_pos < len(source): + insert_pos += 1 # skip the newline + + return source[:insert_pos] + "\n" + code + "\n\n" + source[insert_pos:] + + +def _ensure_react_import(source: str) -> str: + """Ensure React is imported (needed for React.Profiler).""" + if "import React" in source or "import * as React" in source: + return source + # Add React import at the top + if "from 'react'" in source or 'from "react"' in source: + # React is imported but maybe not as the default. That's fine for JSX. + # We need React.Profiler so add it + if "React" not in source.split("from")[0] if "from" in source else "": + return 'import React from "react";\n' + source + return source + return 'import React from "react";\n' + source diff --git a/codeflash/languages/javascript/frameworks/react/testgen.py b/codeflash/languages/javascript/frameworks/react/testgen.py new file mode 100644 index 000000000..fd621b05e --- /dev/null +++ b/codeflash/languages/javascript/frameworks/react/testgen.py @@ -0,0 +1,120 @@ +"""React-specific test generation helpers. + +Provides context building for React testgen prompts, re-render counting +test templates, and post-processing for generated React tests. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from codeflash.languages.base import CodeContext + from codeflash.languages.javascript.frameworks.react.context import ReactContext + from codeflash.languages.javascript.frameworks.react.discovery import ReactComponentInfo + + +def build_react_testgen_context( + component_info: ReactComponentInfo, + react_context: ReactContext, + code_context: CodeContext, +) -> dict: + """Assemble context dict for the React testgen LLM prompt.""" + return { + "component_name": component_info.function_name, + "component_type": component_info.component_type.value, + "component_source": code_context.target_code, + "props_interface": react_context.props_interface or "", + "hooks_used": [h.name for h in react_context.hooks_used], + "child_components": react_context.child_components, + "context_subscriptions": react_context.context_subscriptions, + "is_memoized": component_info.is_memoized, + "optimization_opportunities": [ + {"type": o.type.value, "line": o.line, "description": o.description} + for o in react_context.optimization_opportunities + ], + "read_only_context": code_context.read_only_context, + "imports": code_context.imports, + } + + +def generate_rerender_test_template(component_name: str, props_interface: str | None = None) -> str: + """Generate a template test that counts re-renders for a component. + + This template uses @testing-library/react's render + rerender to verify + that same props don't cause unnecessary re-renders. + """ + props_example = "{ /* same props */ }" if not props_interface else "{ /* fill in props matching interface */ }" + + return f"""\ +import {{ render }} from '@testing-library/react'; +import {{ {component_name} }} from './path-to-component'; + +describe('{component_name} render efficiency', () => {{ + it('should not re-render with same props', () => {{ + let renderCount = 0; + const OriginalComponent = {component_name}; + + // Wrap to count renders + const CountingComponent = (props) => {{ + renderCount++; + return ; + }}; + + const props = {props_example}; + const {{ rerender }} = render(); + + // Initial render + expect(renderCount).toBe(1); + + // Re-render with same props + rerender(); + + // Should not have re-rendered (if properly memoized) + // For non-memoized components, renderCount will be 2 + console.log(`!######REACT_RENDER:{component_name}:rerender_test:0:0:${{renderCount}}######!`); + }}); + + it('should render correctly with props', () => {{ + const props = {props_example}; + const {{ container }} = render(<{component_name} {{...props}} />); + expect(container).toBeTruthy(); + }}); +}}); +""" + + +def post_process_react_tests(test_source: str, component_info: ReactComponentInfo) -> str: + """Post-process LLM-generated React tests. + + Ensures: + - @testing-library/react imports are present + - act() wrapping for state updates + - Proper cleanup + """ + result = test_source + + # Ensure testing-library import + if "@testing-library/react" not in result: + result = "import { render, screen, act } from '@testing-library/react';\n" + result + + # Ensure act import if state updates are detected + if "act(" in result and "import" in result and "act" not in result.split("from '@testing-library/react'")[0]: + result = result.replace( + "from '@testing-library/react'", + "act, " + "from '@testing-library/react'", + 1, + ) + + # Ensure user-event import if user interactions are tested + if ("click" in result.lower() or "type" in result.lower() or "userEvent" in result) and "@testing-library/user-event" not in result: + # Add user-event import after testing-library import + result = re.sub( + r"(import .+ from '@testing-library/react';?\n)", + r"\1import userEvent from '@testing-library/user-event';\n", + result, + count=1, + ) + + return result diff --git a/codeflash/languages/javascript/parse.py b/codeflash/languages/javascript/parse.py index e3eee4831..03aee9d38 100644 --- a/codeflash/languages/javascript/parse.py +++ b/codeflash/languages/javascript/parse.py @@ -10,6 +10,7 @@ import contextlib import json import re +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -31,6 +32,43 @@ jest_start_pattern = re.compile(r"!\$######([^:]+):([^:]+):([^:]+):([^:]+):([^#]+)######\$!") jest_end_pattern = re.compile(r"!######([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):(\d+)######!") +# React Profiler render marker pattern +# Format: !######REACT_RENDER:{component}:{phase}:{actualDuration}:{baseDuration}:{renderCount}######! +REACT_RENDER_MARKER_PATTERN = re.compile( + r"!######REACT_RENDER:([^:]+):([^:]+):([^:]+):([^:]+):(\d+)######!" +) + + +@dataclass(frozen=True) +class RenderProfile: + """Parsed React Profiler render data from a single marker.""" + + component_name: str + phase: str # "mount" or "update" + actual_duration_ms: float + base_duration_ms: float + render_count: int + + +def parse_react_render_markers(stdout: str) -> list[RenderProfile]: + """Parse React Profiler render markers from test output. + + Returns a list of RenderProfile instances, one per marker found. + """ + profiles: list[RenderProfile] = [] + for match in REACT_RENDER_MARKER_PATTERN.finditer(stdout): + try: + profiles.append(RenderProfile( + component_name=match.group(1), + phase=match.group(2), + actual_duration_ms=float(match.group(3)), + base_duration_ms=float(match.group(4)), + render_count=int(match.group(5)), + )) + except (ValueError, IndexError) as e: + logger.debug("Failed to parse React render marker: %s", e) + return profiles + def _extract_jest_console_output(suite_elem) -> str: """Extract console output from Jest's JUnit XML system-out element. diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index e0111c634..45007caaa 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -22,6 +22,7 @@ from collections.abc import Sequence from codeflash.languages.base import ReferenceInfo + from codeflash.languages.javascript.frameworks.detector import FrameworkInfo from codeflash.languages.javascript.treesitter import TypeDefinition from codeflash.models.models import GeneratedTestsList, InvocationId @@ -68,6 +69,22 @@ def comment_prefix(self) -> str: def dir_excludes(self) -> frozenset[str]: return frozenset({"node_modules", "dist", "build", ".next", ".nuxt", "coverage", ".cache", ".turbo", ".vercel"}) + _cached_framework_info: FrameworkInfo | None = None + _cached_framework_root: Path | None = None + + def get_framework_info(self, project_root: Path) -> FrameworkInfo: + """Get cached framework info for the project.""" + if self._cached_framework_root != project_root or self._cached_framework_info is None: + from codeflash.languages.javascript.frameworks.detector import detect_framework # noqa: PLC0415 + + self._cached_framework_info = detect_framework(project_root) + self._cached_framework_root = project_root + return self._cached_framework_info + + def is_react_project(self, project_root: Path) -> bool: + """Check if the project uses React.""" + return self.get_framework_info(project_root).name == "react" + # === Discovery === def discover_functions( @@ -99,6 +116,31 @@ def discover_functions( source, include_methods=criteria.include_methods, include_arrow_functions=True, require_name=True ) + # Build React component lookup if this is a React project + react_component_map: dict[str, Any] = {} + project_root = file_path.parent # Will be refined by caller + try: + from codeflash.languages.javascript.frameworks.react.discovery import ( # noqa: PLC0415 + classify_component, + ) + + for func in tree_functions: + comp_type = classify_component(func, source, analyzer) + if comp_type is not None: + from codeflash.languages.javascript.frameworks.react.discovery import ( # noqa: PLC0415 + _extract_hooks_used, + _is_wrapped_in_memo, + ) + + react_component_map[func.name] = { + "component_type": comp_type.value, + "hooks_used": _extract_hooks_used(func.source_text), + "is_memoized": _is_wrapped_in_memo(func, source), + "is_react_component": True, + } + except Exception as e: + logger.debug("React detection skipped: %s", e) + functions: list[FunctionToOptimize] = [] for func in tree_functions: # Check for return statement if required @@ -122,6 +164,9 @@ def discover_functions( if func.parent_function: parents.append(FunctionParent(name=func.parent_function, type="FunctionDef")) + # Attach React metadata if this function is a component + metadata = react_component_map.get(func.name) + functions.append( FunctionToOptimize( function_name=func.name, @@ -135,6 +180,7 @@ def discover_functions( is_method=func.is_method, language=str(self.language), doc_start_line=func.doc_start_line, + metadata=metadata, ) ) @@ -423,6 +469,33 @@ def extract_code_context(self, function: FunctionToOptimize, project_root: Path, else: read_only_context = type_definitions_context + # Append React-specific context if this is a React component + react_context_str = "" + if function.metadata and function.metadata.get("is_react_component"): + try: + from codeflash.languages.javascript.frameworks.react.discovery import ( # noqa: PLC0415 + ReactComponentInfo, + find_react_components, + ) + from codeflash.languages.javascript.frameworks.react.context import ( # noqa: PLC0415 + extract_react_context, + ) + + components = find_react_components(source, function.file_path, analyzer) + for comp in components: + if comp.function_name == function.function_name: + react_ctx = extract_react_context(comp, source, analyzer, module_root) + react_context_str = react_ctx.to_prompt_string() + if react_context_str: + react_header = "\n\n// === React Component Context ===\n" + if read_only_context: + read_only_context = read_only_context + react_header + react_context_str + else: + read_only_context = react_context_str + break + except Exception as e: + logger.debug("React context extraction failed: %s", e) + # Validate that the extracted code is syntactically valid # If not, raise an error to fail the optimization early if target_code and not self.validate_syntax(target_code): @@ -440,6 +513,7 @@ def extract_code_context(self, function: FunctionToOptimize, project_root: Path, read_only_context=read_only_context, imports=import_lines, language=Language.JAVASCRIPT, + react_context=react_context_str if react_context_str else None, ) def _find_class_definition( diff --git a/codeflash/languages/javascript/treesitter_utils.py b/codeflash/languages/javascript/treesitter_utils.py new file mode 100644 index 000000000..b6126ec9a --- /dev/null +++ b/codeflash/languages/javascript/treesitter_utils.py @@ -0,0 +1,1588 @@ +"""Tree-sitter utilities for cross-language code analysis. + +This module provides a unified interface for parsing and analyzing code +across multiple languages using tree-sitter. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +from tree_sitter import Language, Parser + +if TYPE_CHECKING: + from pathlib import Path + + from tree_sitter import Node, Tree + +logger = logging.getLogger(__name__) + + +class TreeSitterLanguage(Enum): + """Supported tree-sitter languages.""" + + JAVASCRIPT = "javascript" + TYPESCRIPT = "typescript" + TSX = "tsx" + + +# Lazy-loaded language instances +_LANGUAGE_CACHE: dict[TreeSitterLanguage, Language] = {} + + +def _get_language(lang: TreeSitterLanguage) -> Language: + """Get a tree-sitter Language instance, with lazy loading.""" + if lang not in _LANGUAGE_CACHE: + if lang == TreeSitterLanguage.JAVASCRIPT: + import tree_sitter_javascript + + _LANGUAGE_CACHE[lang] = Language(tree_sitter_javascript.language()) + elif lang == TreeSitterLanguage.TYPESCRIPT: + import tree_sitter_typescript + + _LANGUAGE_CACHE[lang] = Language(tree_sitter_typescript.language_typescript()) + elif lang == TreeSitterLanguage.TSX: + import tree_sitter_typescript + + _LANGUAGE_CACHE[lang] = Language(tree_sitter_typescript.language_tsx()) + return _LANGUAGE_CACHE[lang] + + +@dataclass +class FunctionNode: + """Represents a function found by tree-sitter analysis.""" + + name: str + node: Node + start_line: int + end_line: int + start_col: int + end_col: int + is_async: bool + is_method: bool + is_arrow: bool + is_generator: bool + class_name: str | None + parent_function: str | None + source_text: str + doc_start_line: int | None = None # Line where JSDoc comment starts (or None if no JSDoc) + + +@dataclass +class ImportInfo: + """Represents an import statement.""" + + module_path: str # The path being imported from + default_import: str | None # Default import name (import X from ...) + named_imports: list[tuple[str, str | None]] # [(name, alias), ...] + namespace_import: str | None # Namespace import (import * as X from ...) + is_type_only: bool # TypeScript type-only import + start_line: int + end_line: int + + +@dataclass +class ExportInfo: + """Represents an export statement.""" + + exported_names: list[tuple[str, str | None]] # [(name, alias), ...] for named exports + default_export: str | None # Name of default exported function/class/value + is_reexport: bool # Whether this is a re-export (export { x } from './other') + reexport_source: str | None # Module path for re-exports + start_line: int + end_line: int + + +@dataclass +class ModuleLevelDeclaration: + """Represents a module-level (global) variable or constant declaration.""" + + name: str # Variable/constant name + declaration_type: str # "const", "let", "var", "class", "enum", "type", "interface" + source_code: str # Full declaration source code + start_line: int + end_line: int + is_exported: bool # Whether the declaration is exported + + +@dataclass +class TypeDefinition: + """Represents a type definition (interface, type alias, class, or enum).""" + + name: str # Type name + definition_type: str # "interface", "type", "class", "enum" + source_code: str # Full definition source code + start_line: int + end_line: int + is_exported: bool # Whether the definition is exported + file_path: Path | None = None # File where the type is defined + + +class TreeSitterAnalyzer: + """Cross-language code analysis using tree-sitter. + + This class provides methods to parse and analyze JavaScript/TypeScript code, + finding functions, imports, and other code structures. + """ + + def __init__(self, language: TreeSitterLanguage | str) -> None: + """Initialize the analyzer for a specific language. + + Args: + language: The language to analyze (TreeSitterLanguage enum or string). + + """ + if isinstance(language, str): + language = TreeSitterLanguage(language) + self.language = language + self._parser: Parser | None = None + + @property + def parser(self) -> Parser: + """Get the parser, creating it lazily.""" + if self._parser is None: + self._parser = Parser(_get_language(self.language)) + return self._parser + + def parse(self, source: str | bytes) -> Tree: + """Parse source code into a tree-sitter tree. + + Args: + source: Source code as string or bytes. + + Returns: + The parsed tree. + + """ + if isinstance(source, str): + source = source.encode("utf8") + return self.parser.parse(source) + + def get_node_text(self, node: Node, source: bytes) -> str: + """Extract the source text for a tree-sitter node. + + Args: + node: The tree-sitter node. + source: The source code as bytes. + + Returns: + The text content of the node. + + """ + return source[node.start_byte : node.end_byte].decode("utf8") + + def find_functions( + self, source: str, include_methods: bool = True, include_arrow_functions: bool = True, require_name: bool = True + ) -> list[FunctionNode]: + """Find all function definitions in source code. + + Args: + source: The source code to analyze. + include_methods: Whether to include class methods. + include_arrow_functions: Whether to include arrow functions. + require_name: Whether to require functions to have names. + + Returns: + List of FunctionNode objects describing found functions. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + functions: list[FunctionNode] = [] + + self._walk_tree_for_functions( + tree.root_node, + source_bytes, + functions, + include_methods=include_methods, + include_arrow_functions=include_arrow_functions, + require_name=require_name, + current_class=None, + current_function=None, + ) + + return functions + + def _walk_tree_for_functions( + self, + node: Node, + source_bytes: bytes, + functions: list[FunctionNode], + include_methods: bool, + include_arrow_functions: bool, + require_name: bool, + current_class: str | None, + current_function: str | None, + ) -> None: + """Recursively walk the tree to find function definitions.""" + # Function types in JavaScript/TypeScript + function_types = { + "function_declaration", + "function_expression", + "generator_function_declaration", + "generator_function", + } + + if include_arrow_functions: + function_types.add("arrow_function") + + if include_methods: + function_types.add("method_definition") + + # Track class context + new_class = current_class + new_function = current_function + + if node.type in {"class_declaration", "class"}: + # Get class name + name_node = node.child_by_field_name("name") + if name_node: + new_class = self.get_node_text(name_node, source_bytes) + + if node.type in function_types: + func_info = self._extract_function_info(node, source_bytes, current_class, current_function) + + if func_info: + # Check if we should include this function + should_include = True + + if require_name and not func_info.name: + should_include = False + + if func_info.is_method and not include_methods: + should_include = False + + if func_info.is_arrow and not include_arrow_functions: + should_include = False + + # Skip arrow functions that are object properties (e.g., { foo: () => {} }) + # These are not standalone functions - they're values in object literals + if func_info.is_arrow and node.parent and node.parent.type == "pair": + should_include = False + + if should_include: + functions.append(func_info) + + # Track as current function for nested functions + if func_info.name: + new_function = func_info.name + + # Recurse into children + for child in node.children: + self._walk_tree_for_functions( + child, + source_bytes, + functions, + include_methods=include_methods, + include_arrow_functions=include_arrow_functions, + require_name=require_name, + current_class=new_class, + current_function=new_function if node.type in function_types else current_function, + ) + + def _extract_function_info( + self, node: Node, source_bytes: bytes, current_class: str | None, current_function: str | None + ) -> FunctionNode | None: + """Extract function information from a tree-sitter node.""" + name = "" + is_async = False + is_generator = False + is_method = False + is_arrow = node.type == "arrow_function" + + # Check for async modifier + for child in node.children: + if child.type == "async": + is_async = True + break + + # Check for generator + if "generator" in node.type: + is_generator = True + + # Get function name based on node type + if node.type in ("function_declaration", "generator_function_declaration"): + name_node = node.child_by_field_name("name") + if name_node: + name = self.get_node_text(name_node, source_bytes) + else: + # Fallback: search for identifier child (some tree-sitter versions) + for child in node.children: + if child.type == "identifier": + name = self.get_node_text(child, source_bytes) + break + elif node.type == "method_definition": + is_method = True + name_node = node.child_by_field_name("name") + if name_node: + name = self.get_node_text(name_node, source_bytes) + elif node.type in ("function_expression", "generator_function"): + # Check if assigned to a variable + name_node = node.child_by_field_name("name") + if name_node: + name = self.get_node_text(name_node, source_bytes) + else: + # Try to get name from parent assignment + name = self._get_name_from_assignment(node, source_bytes) + elif node.type == "arrow_function": + # Arrow functions get names from variable declarations + name = self._get_name_from_assignment(node, source_bytes) + + # Get source text + source_text = self.get_node_text(node, source_bytes) + + # Find preceding JSDoc comment + doc_start_line = self._find_preceding_jsdoc(node, source_bytes) + + return FunctionNode( + name=name, + node=node, + start_line=node.start_point[0] + 1, # Convert to 1-indexed + end_line=node.end_point[0] + 1, + start_col=node.start_point[1], + end_col=node.end_point[1], + is_async=is_async, + is_method=is_method, + is_arrow=is_arrow, + is_generator=is_generator, + class_name=current_class if is_method else None, + parent_function=current_function, + source_text=source_text, + doc_start_line=doc_start_line, + ) + + def _find_preceding_jsdoc(self, node: Node, source_bytes: bytes) -> int | None: + """Find JSDoc comment immediately preceding a function node. + + For regular functions, looks at the previous sibling of the function node. + For arrow functions assigned to variables, looks at the previous sibling + of the variable declaration. + + Args: + node: The function node to find JSDoc for. + source_bytes: The source code as bytes. + + Returns: + The start line (1-indexed) of the JSDoc, or None if no JSDoc found. + + """ + target_node = node + + # For arrow functions, look at parent variable declaration + if node.type == "arrow_function": + parent = node.parent + if parent and parent.type == "variable_declarator": + grandparent = parent.parent + if grandparent and grandparent.type in ("lexical_declaration", "variable_declaration"): + target_node = grandparent + + # For function expressions assigned to variables, also look at parent + if node.type in ("function_expression", "generator_function"): + parent = node.parent + if parent and parent.type == "variable_declarator": + grandparent = parent.parent + if grandparent and grandparent.type in ("lexical_declaration", "variable_declaration"): + target_node = grandparent + + # Get the previous sibling node + prev_sibling = target_node.prev_named_sibling + + # Check if it's a comment node with JSDoc pattern + if prev_sibling and prev_sibling.type == "comment": + comment_text = self.get_node_text(prev_sibling, source_bytes) + if comment_text.strip().startswith("/**"): + # Verify it's immediately preceding (no blank lines between) + comment_end_line = prev_sibling.end_point[0] + function_start_line = target_node.start_point[0] + if function_start_line - comment_end_line <= 1: + return prev_sibling.start_point[0] + 1 # 1-indexed + + return None + + def _get_name_from_assignment(self, node: Node, source_bytes: bytes) -> str: + """Try to extract function name from parent variable declaration or assignment. + + Handles patterns like: + - const foo = () => {} + - const foo = function() {} + - let bar = function() {} + - obj.method = () => {} + """ + parent = node.parent + if parent is None: + return "" + + # Check for variable declarator: const foo = ... + if parent.type == "variable_declarator": + name_node = parent.child_by_field_name("name") + if name_node: + return self.get_node_text(name_node, source_bytes) + + # Check for assignment expression: foo = ... + if parent.type == "assignment_expression": + left_node = parent.child_by_field_name("left") + if left_node: + if left_node.type == "identifier": + return self.get_node_text(left_node, source_bytes) + if left_node.type == "member_expression": + # For obj.method = ..., get the property name + prop_node = left_node.child_by_field_name("property") + if prop_node: + return self.get_node_text(prop_node, source_bytes) + + # Check for property in object: { foo: () => {} } + if parent.type == "pair": + key_node = parent.child_by_field_name("key") + if key_node: + return self.get_node_text(key_node, source_bytes) + + return "" + + def find_imports(self, source: str) -> list[ImportInfo]: + """Find all import statements in source code. + + Args: + source: The source code to analyze. + + Returns: + List of ImportInfo objects describing imports. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + imports: list[ImportInfo] = [] + + self._walk_tree_for_imports(tree.root_node, source_bytes, imports) + + return imports + + def _walk_tree_for_imports( + self, node: Node, source_bytes: bytes, imports: list[ImportInfo], in_function: bool = False + ) -> None: + """Recursively walk the tree to find import statements. + + Args: + node: Current node to check. + source_bytes: Source code bytes. + imports: List to append found imports to. + in_function: Whether we're currently inside a function/method body. + + """ + # Track when we enter function/method bodies + # These node types contain function/method bodies where require() should not be treated as imports + function_body_types = { + "function_declaration", + "method_definition", + "arrow_function", + "function_expression", + "function", # Generic function in some grammars + } + + if node.type == "import_statement": + import_info = self._extract_import_info(node, source_bytes) + if import_info: + imports.append(import_info) + + # Also handle require() calls for CommonJS, but only at module level + # require() inside functions is a dynamic import, not a module import + if node.type == "call_expression" and not in_function: + func_node = node.child_by_field_name("function") + if func_node and self.get_node_text(func_node, source_bytes) == "require": + import_info = self._extract_require_info(node, source_bytes) + if import_info: + imports.append(import_info) + + # Update in_function flag for children + child_in_function = in_function or node.type in function_body_types + + for child in node.children: + self._walk_tree_for_imports(child, source_bytes, imports, child_in_function) + + def _extract_import_info(self, node: Node, source_bytes: bytes) -> ImportInfo | None: + """Extract import information from an import statement node.""" + module_path = "" + default_import = None + named_imports: list[tuple[str, str | None]] = [] + namespace_import = None + is_type_only = False + + # Get the module path (source) + source_node = node.child_by_field_name("source") + if source_node: + # Remove quotes from string + module_path = self.get_node_text(source_node, source_bytes).strip("'\"") + + # Check for type-only import (TypeScript) + for child in node.children: + if child.type == "type" or self.get_node_text(child, source_bytes) == "type": + is_type_only = True + break + + # Process import clause + for child in node.children: + if child.type == "import_clause": + self._process_import_clause(child, source_bytes, default_import, named_imports, namespace_import) + # Re-extract after processing + for clause_child in child.children: + if clause_child.type == "identifier": + default_import = self.get_node_text(clause_child, source_bytes) + elif clause_child.type == "named_imports": + for spec in clause_child.children: + if spec.type == "import_specifier": + name_node = spec.child_by_field_name("name") + alias_node = spec.child_by_field_name("alias") + if name_node: + name = self.get_node_text(name_node, source_bytes) + alias = self.get_node_text(alias_node, source_bytes) if alias_node else None + named_imports.append((name, alias)) + elif clause_child.type == "namespace_import": + # import * as X + for ns_child in clause_child.children: + if ns_child.type == "identifier": + namespace_import = self.get_node_text(ns_child, source_bytes) + + if not module_path: + return None + + return ImportInfo( + module_path=module_path, + default_import=default_import, + named_imports=named_imports, + namespace_import=namespace_import, + is_type_only=is_type_only, + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + ) + + def _process_import_clause( + self, + node: Node, + source_bytes: bytes, + default_import: str | None, + named_imports: list[tuple[str, str | None]], + namespace_import: str | None, + ) -> None: + """Process an import clause to extract imports.""" + # This is a helper that modifies the lists in place + # Processing is done inline in _extract_import_info + + def _extract_require_info(self, node: Node, source_bytes: bytes) -> ImportInfo | None: + """Extract import information from a require() call. + + Handles various CommonJS require patterns: + - const foo = require('./module') -> default import + - const { a, b } = require('./module') -> named imports + - const { a: aliasA } = require('./module') -> named imports with alias + - const foo = require('./module').bar -> property access (named import) + - require('./module') -> side effect import + """ + # Handle require().property pattern - the call_expression is inside member_expression + actual_require_node = node + property_access = None + + # Check if this require is part of a member_expression like require('./m').foo + if node.parent and node.parent.type == "member_expression": + member_node = node.parent + prop_node = member_node.child_by_field_name("property") + if prop_node: + property_access = self.get_node_text(prop_node, source_bytes) + # Use the member expression's parent for variable assignment lookup + node = member_node + + args_node = actual_require_node.child_by_field_name("arguments") + if not args_node: + return None + + # Get the first argument (module path) + module_path = "" + for child in args_node.children: + if child.type == "string": + module_path = self.get_node_text(child, source_bytes).strip("'\"") + break + + if not module_path: + return None + + # Try to get the variable name from assignment + default_import = None + named_imports: list[tuple[str, str | None]] = [] + + parent = node.parent + if parent and parent.type == "variable_declarator": + name_node = parent.child_by_field_name("name") + if name_node: + if name_node.type == "identifier": + var_name = self.get_node_text(name_node, source_bytes) + if property_access: + # const foo = require('./module').bar + # This imports 'bar' from the module and assigns to 'foo' + named_imports.append((property_access, var_name if var_name != property_access else None)) + else: + # const foo = require('./module') + default_import = var_name + elif name_node.type == "object_pattern": + # Destructuring: const { a, b } = require('...') + named_imports = self._extract_object_pattern_names(name_node, source_bytes) + elif property_access: + # require('./module').foo without assignment - still track the property access + named_imports.append((property_access, None)) + + return ImportInfo( + module_path=module_path, + default_import=default_import, + named_imports=named_imports, + namespace_import=None, + is_type_only=False, + start_line=actual_require_node.start_point[0] + 1, + end_line=actual_require_node.end_point[0] + 1, + ) + + def _extract_object_pattern_names(self, node: Node, source_bytes: bytes) -> list[tuple[str, str | None]]: + """Extract names from an object pattern (destructuring). + + Handles patterns like: + - { a, b } -> [('a', None), ('b', None)] + - { a: aliasA } -> [('a', 'aliasA')] + - { a, b: aliasB } -> [('a', None), ('b', 'aliasB')] + """ + names: list[tuple[str, str | None]] = [] + + for child in node.children: + if child.type == "shorthand_property_identifier_pattern": + # { a } - shorthand, name equals value + name = self.get_node_text(child, source_bytes) + names.append((name, None)) + elif child.type == "pair_pattern": + # { a: aliasA } - renamed import + key_node = child.child_by_field_name("key") + value_node = child.child_by_field_name("value") + if key_node and value_node: + original_name = self.get_node_text(key_node, source_bytes) + alias = self.get_node_text(value_node, source_bytes) + names.append((original_name, alias)) + + return names + + def find_exports(self, source: str) -> list[ExportInfo]: + """Find all export statements in source code. + + Args: + source: The source code to analyze. + + Returns: + List of ExportInfo objects describing exports. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + exports: list[ExportInfo] = [] + + self._walk_tree_for_exports(tree.root_node, source_bytes, exports) + + return exports + + def _walk_tree_for_exports(self, node: Node, source_bytes: bytes, exports: list[ExportInfo]) -> None: + """Recursively walk the tree to find export statements.""" + # Handle ES module export statements + if node.type == "export_statement": + export_info = self._extract_export_info(node, source_bytes) + if export_info: + exports.append(export_info) + + # Handle CommonJS exports: module.exports = ... or exports.foo = ... + if node.type == "assignment_expression": + export_info = self._extract_commonjs_export(node, source_bytes) + if export_info: + exports.append(export_info) + + for child in node.children: + self._walk_tree_for_exports(child, source_bytes, exports) + + def _extract_export_info(self, node: Node, source_bytes: bytes) -> ExportInfo | None: + """Extract export information from an export statement node.""" + exported_names: list[tuple[str, str | None]] = [] + default_export: str | None = None + is_reexport = False + reexport_source: str | None = None + + # Check for re-export source (export { x } from './other') + source_node = node.child_by_field_name("source") + if source_node: + is_reexport = True + reexport_source = self.get_node_text(source_node, source_bytes).strip("'\"") + + for child in node.children: + # Handle 'export default' + if child.type == "default": + # Find what's being exported as default + for sibling in node.children: + if sibling.type in {"function_declaration", "class_declaration"}: + name_node = sibling.child_by_field_name("name") + default_export = self.get_node_text(name_node, source_bytes) if name_node else "default" + elif sibling.type == "identifier": + default_export = self.get_node_text(sibling, source_bytes) + elif sibling.type in ("arrow_function", "function_expression", "object", "array"): + default_export = "default" + break + + # Handle named exports: export { a, b as c } + if child.type == "export_clause": + for spec in child.children: + if spec.type == "export_specifier": + name_node = spec.child_by_field_name("name") + alias_node = spec.child_by_field_name("alias") + if name_node: + name = self.get_node_text(name_node, source_bytes) + alias = self.get_node_text(alias_node, source_bytes) if alias_node else None + exported_names.append((name, alias)) + + # Handle direct exports: export function foo() {} + if child.type == "function_declaration": + name_node = child.child_by_field_name("name") + if name_node: + name = self.get_node_text(name_node, source_bytes) + exported_names.append((name, None)) + + # Handle direct class exports: export class Foo {} + if child.type == "class_declaration": + name_node = child.child_by_field_name("name") + if name_node: + name = self.get_node_text(name_node, source_bytes) + exported_names.append((name, None)) + + # Handle variable exports: export const foo = ... + if child.type == "lexical_declaration": + for decl in child.children: + if decl.type == "variable_declarator": + name_node = decl.child_by_field_name("name") + if name_node and name_node.type == "identifier": + name = self.get_node_text(name_node, source_bytes) + exported_names.append((name, None)) + + # Skip if no exports found + if not exported_names and not default_export: + return None + + return ExportInfo( + exported_names=exported_names, + default_export=default_export, + is_reexport=is_reexport, + reexport_source=reexport_source, + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + ) + + def _extract_commonjs_export(self, node: Node, source_bytes: bytes) -> ExportInfo | None: + """Extract export information from CommonJS module.exports or exports.* patterns. + + Handles patterns like: + - module.exports = function() {} -> default export + - module.exports = { foo, bar } -> named exports + - module.exports.foo = function() {} -> named export 'foo' + - exports.foo = function() {} -> named export 'foo' + - module.exports = require('./other') -> re-export + """ + left_node = node.child_by_field_name("left") + right_node = node.child_by_field_name("right") + + if not left_node or not right_node: + return None + + # Check if this is a module.exports or exports.* pattern + if left_node.type != "member_expression": + return None + + left_text = self.get_node_text(left_node, source_bytes) + + exported_names: list[tuple[str, str | None]] = [] + default_export: str | None = None + is_reexport = False + reexport_source: str | None = None + + if left_text == "module.exports": + # module.exports = something + if right_node.type in {"function_expression", "arrow_function"}: + # module.exports = function foo() {} or module.exports = () => {} + name_node = right_node.child_by_field_name("name") + default_export = self.get_node_text(name_node, source_bytes) if name_node else "default" + elif right_node.type == "identifier": + # module.exports = someFunction + default_export = self.get_node_text(right_node, source_bytes) + elif right_node.type == "object": + # module.exports = { foo, bar, baz: qux } + for child in right_node.children: + if child.type == "shorthand_property_identifier": + # { foo } - exports function named foo + name = self.get_node_text(child, source_bytes) + exported_names.append((name, None)) + elif child.type == "pair": + # { baz: qux } - exports qux as baz + key_node = child.child_by_field_name("key") + value_node = child.child_by_field_name("value") + if key_node and value_node: + export_name = self.get_node_text(key_node, source_bytes) + local_name = self.get_node_text(value_node, source_bytes) + # In CommonJS { baz: qux }, baz is the exported name, qux is local + exported_names.append((local_name, export_name)) + elif right_node.type == "call_expression": + # module.exports = require('./other') - re-export + func_node = right_node.child_by_field_name("function") + if func_node and self.get_node_text(func_node, source_bytes) == "require": + is_reexport = True + args_node = right_node.child_by_field_name("arguments") + if args_node: + for arg in args_node.children: + if arg.type == "string": + reexport_source = self.get_node_text(arg, source_bytes).strip("'\"") + break + default_export = "default" + else: + # module.exports = something else (class, etc.) + default_export = "default" + + elif left_text.startswith("module.exports."): + # module.exports.foo = something + prop_name = left_text.split(".", 2)[2] # Get 'foo' from 'module.exports.foo' + exported_names.append((prop_name, None)) + + elif left_text.startswith("exports."): + # exports.foo = something + prop_name = left_text.split(".", 1)[1] # Get 'foo' from 'exports.foo' + exported_names.append((prop_name, None)) + + else: + # Not a CommonJS export pattern + return None + + # Skip if no exports found + if not exported_names and not default_export: + return None + + return ExportInfo( + exported_names=exported_names, + default_export=default_export, + is_reexport=is_reexport, + reexport_source=reexport_source, + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + ) + + def is_function_exported( + self, source: str, function_name: str, class_name: str | None = None + ) -> tuple[bool, str | None]: + """Check if a function is exported and get its export name. + + For class methods, also checks if the containing class is exported. + + Args: + source: The source code to analyze. + function_name: The name of the function to check. + class_name: For class methods, the name of the containing class. + + Returns: + Tuple of (is_exported, export_name). export_name may differ from + function_name if exported with an alias. For class methods, + returns the class export name. + + """ + exports = self.find_exports(source) + + # First, check if the function itself is directly exported + for export in exports: + # Check default export + if export.default_export == function_name: + return (True, "default") + + # Check named exports + for name, alias in export.exported_names: + if name == function_name: + return (True, alias or name) + + # For class methods, check if the containing class is exported + if class_name: + for export in exports: + # Check if class is default export + if export.default_export == class_name: + return (True, class_name) + + # Check if class is in named exports + for name, alias in export.exported_names: + if name == class_name: + return (True, alias or name) + + return (False, None) + + def find_function_calls(self, source: str, within_function: FunctionNode) -> list[str]: + """Find all function calls within a specific function's body. + + Args: + source: The full source code. + within_function: The function to search within. + + Returns: + List of function names that are called. + + """ + calls: list[str] = [] + source_bytes = source.encode("utf8") + + # Get the body of the function + body_node = within_function.node.child_by_field_name("body") + if body_node is None: + # For arrow functions, the body might be the last child + for child in within_function.node.children: + if child.type in ("statement_block", "expression_statement") or ( + child.type not in ("identifier", "formal_parameters", "async", "=>") + ): + body_node = child + break + + if body_node: + self._walk_tree_for_calls(body_node, source_bytes, calls) + + return list(set(calls)) # Remove duplicates + + def _walk_tree_for_calls(self, node: Node, source_bytes: bytes, calls: list[str]) -> None: + """Recursively find function calls in a subtree.""" + if node.type == "call_expression": + func_node = node.child_by_field_name("function") + if func_node: + if func_node.type == "identifier": + calls.append(self.get_node_text(func_node, source_bytes)) + elif func_node.type == "member_expression": + # For method calls like obj.method(), get the method name + prop_node = func_node.child_by_field_name("property") + if prop_node: + calls.append(self.get_node_text(prop_node, source_bytes)) + + for child in node.children: + self._walk_tree_for_calls(child, source_bytes, calls) + + def find_module_level_declarations(self, source: str) -> list[ModuleLevelDeclaration]: + """Find all module-level variable/constant declarations. + + This finds global variables, constants, classes, enums, type aliases, + and interfaces defined at the top level of the module (not inside functions). + + Args: + source: The source code to analyze. + + Returns: + List of ModuleLevelDeclaration objects. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + declarations: list[ModuleLevelDeclaration] = [] + + # Only look at direct children of the program/module node (top-level) + for child in tree.root_node.children: + self._extract_module_level_declaration(child, source_bytes, declarations) + + return declarations + + def _extract_module_level_declaration( + self, node: Node, source_bytes: bytes, declarations: list[ModuleLevelDeclaration] + ) -> None: + """Extract module-level declarations from a node.""" + is_exported = False + + # Handle export statements - unwrap to get the actual declaration + if node.type == "export_statement": + is_exported = True + # Find the actual declaration inside the export + for child in node.children: + if child.type in ("lexical_declaration", "variable_declaration"): + self._extract_declaration(child, source_bytes, declarations, is_exported, node) + return + if child.type == "class_declaration": + name_node = child.child_by_field_name("name") + if name_node: + declarations.append( + ModuleLevelDeclaration( + name=self.get_node_text(name_node, source_bytes), + declaration_type="class", + source_code=self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + return + if child.type in ("type_alias_declaration", "interface_declaration", "enum_declaration"): + name_node = child.child_by_field_name("name") + if name_node: + decl_type = child.type.replace("_declaration", "").replace("_alias", "") + declarations.append( + ModuleLevelDeclaration( + name=self.get_node_text(name_node, source_bytes), + declaration_type=decl_type, + source_code=self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + return + return + + # Handle non-exported declarations + if node.type in ( + "lexical_declaration", # const/let + "variable_declaration", # var + ): + self._extract_declaration(node, source_bytes, declarations, is_exported, node) + elif node.type == "class_declaration": + name_node = node.child_by_field_name("name") + if name_node: + declarations.append( + ModuleLevelDeclaration( + name=self.get_node_text(name_node, source_bytes), + declaration_type="class", + source_code=self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + elif node.type in ("type_alias_declaration", "interface_declaration", "enum_declaration"): + name_node = node.child_by_field_name("name") + if name_node: + decl_type = node.type.replace("_declaration", "").replace("_alias", "") + declarations.append( + ModuleLevelDeclaration( + name=self.get_node_text(name_node, source_bytes), + declaration_type=decl_type, + source_code=self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + + def _extract_declaration( + self, + node: Node, + source_bytes: bytes, + declarations: list[ModuleLevelDeclaration], + is_exported: bool, + source_node: Node, + ) -> None: + """Extract variable declarations (const/let/var).""" + # Determine declaration type (const, let, var) + decl_type = "var" + for child in node.children: + if child.type in ("const", "let", "var"): + decl_type = child.type + break + + # Find variable declarators + for child in node.children: + if child.type == "variable_declarator": + name_node = child.child_by_field_name("name") + if name_node: + # Handle destructuring patterns + if name_node.type == "identifier": + declarations.append( + ModuleLevelDeclaration( + name=self.get_node_text(name_node, source_bytes), + declaration_type=decl_type, + source_code=self.get_node_text(source_node, source_bytes), + start_line=source_node.start_point[0] + 1, + end_line=source_node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + elif name_node.type in ("object_pattern", "array_pattern"): + # For destructuring, extract all bound identifiers + identifiers = self._extract_pattern_identifiers(name_node, source_bytes) + for ident in identifiers: + declarations.append( + ModuleLevelDeclaration( + name=ident, + declaration_type=decl_type, + source_code=self.get_node_text(source_node, source_bytes), + start_line=source_node.start_point[0] + 1, + end_line=source_node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + + def _extract_pattern_identifiers(self, pattern_node: Node, source_bytes: bytes) -> list[str]: + """Extract all identifier names from a destructuring pattern.""" + identifiers: list[str] = [] + + def walk(n: Node) -> None: + if n.type in {"identifier", "shorthand_property_identifier_pattern"}: + identifiers.append(self.get_node_text(n, source_bytes)) + for child in n.children: + walk(child) + + walk(pattern_node) + return identifiers + + def find_referenced_identifiers(self, source: str) -> set[str]: + """Find all identifiers referenced in the source code. + + This finds all identifier references, excluding: + - Declaration names (left side of assignments) + - Property names in object literals + - Function/class names at definition site + + Args: + source: The source code to analyze. + + Returns: + Set of referenced identifier names. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + references: set[str] = set() + + self._walk_tree_for_references(tree.root_node, source_bytes, references) + + return references + + def _walk_tree_for_references(self, node: Node, source_bytes: bytes, references: set[str]) -> None: + """Walk tree to collect identifier references.""" + if node.type == "identifier": + # Check if this identifier is a reference (not a declaration) + parent = node.parent + if parent is None: + return + + # Skip function/class/method names at definition + if parent.type in ("function_declaration", "class_declaration", "method_definition", "function_expression"): + if parent.child_by_field_name("name") == node: + # Don't recurse into parent's children - the parent will be visited separately + return + + # Skip variable declarator names (left side of declaration) + if parent.type == "variable_declarator" and parent.child_by_field_name("name") == node: + # Don't recurse - the value will be visited when we visit the declarator + return + + # Skip property names in object literals (keys) + if parent.type == "pair" and parent.child_by_field_name("key") == node: + # Don't recurse - the value will be visited when we visit the pair + return + + # Skip property access property names (obj.property - skip 'property') + if parent.type == "member_expression" and parent.child_by_field_name("property") == node: + # Don't recurse - the object will be visited when we visit the member_expression + return + + # Skip import specifier names + if parent.type in ("import_specifier", "import_clause", "namespace_import"): + return + + # Skip export specifier names + if parent.type == "export_specifier": + return + + # Skip parameter names in function definitions (but NOT default values) + if parent.type == "formal_parameters": + return + if parent.type == "required_parameter": + # Only skip if this is the parameter name (pattern field), not the default value + if parent.child_by_field_name("pattern") == node: + return + # If it's the value field (default value), it's a reference - don't skip + + # This is a reference + references.add(self.get_node_text(node, source_bytes)) + return + + # Recurse into children + for child in node.children: + self._walk_tree_for_references(child, source_bytes, references) + + def has_return_statement(self, function_node: FunctionNode, source: str) -> bool: + """Check if a function has a return statement. + + Args: + function_node: The function to check. + source: The source code. + + Returns: + True if the function has a return statement. + + """ + source_bytes = source.encode("utf8") + + # Generator functions always implicitly return a Generator/Iterator + if function_node.is_generator: + return True + + # For arrow functions with expression body, there's an implicit return + if function_node.is_arrow: + body_node = function_node.node.child_by_field_name("body") + if body_node and body_node.type != "statement_block": + # Expression body (implicit return) + return True + + return self._node_has_return(function_node.node) + + def _node_has_return(self, node: Node) -> bool: + """Recursively check if a node contains a return statement.""" + if node.type == "return_statement": + return True + + # Don't recurse into nested function definitions + if node.type in ("function_declaration", "function_expression", "arrow_function", "method_definition"): + # Only check the current function, not nested ones + body_node = node.child_by_field_name("body") + if body_node: + for child in body_node.children: + if self._node_has_return(child): + return True + return False + + return any(self._node_has_return(child) for child in node.children) + + def extract_type_annotations(self, source: str, function_name: str, function_line: int) -> set[str]: + """Extract type annotation names from a function's parameters and return type. + + Finds the function by name and line number, then extracts all user-defined type names + from its type annotations (parameters and return type). + + Args: + source: The source code to analyze. + function_name: Name of the function to find. + function_line: Start line of the function (1-indexed). + + Returns: + Set of type names found in the function's annotations. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + type_names: set[str] = set() + + # Find the function node + func_node = self._find_function_node(tree.root_node, source_bytes, function_name, function_line) + if not func_node: + return type_names + + # Extract type annotations from parameters + params_node = func_node.child_by_field_name("parameters") + if params_node: + self._extract_type_names_from_node(params_node, source_bytes, type_names) + + # Extract return type annotation + return_type_node = func_node.child_by_field_name("return_type") + if return_type_node: + self._extract_type_names_from_node(return_type_node, source_bytes, type_names) + + return type_names + + def extract_class_field_types(self, source: str, class_name: str) -> set[str]: + """Extract type annotation names from class field declarations. + + Args: + source: The source code to analyze. + class_name: Name of the class to analyze. + + Returns: + Set of type names found in class field annotations. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + type_names: set[str] = set() + + # Find the class node + class_node = self._find_class_node(tree.root_node, source_bytes, class_name) + if not class_node: + return type_names + + # Find class body and extract field type annotations + body_node = class_node.child_by_field_name("body") + if body_node: + for child in body_node.children: + # Handle public_field_definition (JS/TS class fields) + if child.type in ("public_field_definition", "field_definition"): + type_annotation = child.child_by_field_name("type") + if type_annotation: + self._extract_type_names_from_node(type_annotation, source_bytes, type_names) + + return type_names + + def _find_function_node( + self, node: Node, source_bytes: bytes, function_name: str, function_line: int + ) -> Node | None: + """Find a function/method node by name and line number.""" + if node.type in ( + "function_declaration", + "method_definition", + "function_expression", + "generator_function_declaration", + ): + name_node = node.child_by_field_name("name") + if name_node: + name = self.get_node_text(name_node, source_bytes) + # Line is 1-indexed, tree-sitter is 0-indexed + if name == function_name and (node.start_point[0] + 1) == function_line: + return node + + # Check arrow functions assigned to variables + if node.type == "lexical_declaration": + for child in node.children: + if child.type == "variable_declarator": + name_node = child.child_by_field_name("name") + value_node = child.child_by_field_name("value") + if name_node and value_node and value_node.type == "arrow_function": + name = self.get_node_text(name_node, source_bytes) + if name == function_name and (node.start_point[0] + 1) == function_line: + return value_node + + # Recurse into children + for child in node.children: + result = self._find_function_node(child, source_bytes, function_name, function_line) + if result: + return result + + return None + + def _find_class_node(self, node: Node, source_bytes: bytes, class_name: str) -> Node | None: + """Find a class node by name.""" + if node.type in ("class_declaration", "class"): + name_node = node.child_by_field_name("name") + if name_node: + name = self.get_node_text(name_node, source_bytes) + if name == class_name: + return node + + for child in node.children: + result = self._find_class_node(child, source_bytes, class_name) + if result: + return result + + return None + + def _extract_type_names_from_node(self, node: Node, source_bytes: bytes, type_names: set[str]) -> None: + """Recursively extract type names from a type annotation node. + + Handles various TypeScript type annotation patterns: + - Simple types: number, string, Point + - Generic types: Array, Promise + - Union types: A | B + - Intersection types: A & B + - Array types: T[] + - Tuple types: [A, B] + - Object/mapped types: { key: Type } + + Args: + node: Tree-sitter node to analyze. + source_bytes: Source code as bytes. + type_names: Set to add found type names to. + + """ + # Handle type identifiers (the actual type name references) + if node.type == "type_identifier": + type_name = self.get_node_text(node, source_bytes) + # Skip primitive types + if type_name not in ( + "number", + "string", + "boolean", + "void", + "null", + "undefined", + "any", + "never", + "unknown", + "object", + "symbol", + "bigint", + ): + type_names.add(type_name) + return + + # Handle regular identifiers in type position (can happen in some contexts) + if node.type == "identifier" and node.parent and node.parent.type in ("type_annotation", "generic_type"): + type_name = self.get_node_text(node, source_bytes) + if type_name not in ( + "number", + "string", + "boolean", + "void", + "null", + "undefined", + "any", + "never", + "unknown", + "object", + "symbol", + "bigint", + ): + type_names.add(type_name) + return + + # Handle nested_type_identifier (e.g., Namespace.Type) + if node.type == "nested_type_identifier": + # Get the full qualified name + type_name = self.get_node_text(node, source_bytes) + # Add both the full name and the first part (namespace) + type_names.add(type_name) + # Also extract the module/namespace part + module_node = node.child_by_field_name("module") + if module_node: + type_names.add(self.get_node_text(module_node, source_bytes)) + return + + # Recurse into all children for compound types + for child in node.children: + self._extract_type_names_from_node(child, source_bytes, type_names) + + def find_type_definitions(self, source: str) -> list[TypeDefinition]: + """Find all type definitions (interface, type, class, enum) in source code. + + Args: + source: The source code to analyze. + + Returns: + List of TypeDefinition objects. + + """ + source_bytes = source.encode("utf8") + tree = self.parse(source_bytes) + definitions: list[TypeDefinition] = [] + + # Walk through top-level nodes + for child in tree.root_node.children: + self._extract_type_definition(child, source_bytes, definitions) + + return definitions + + def _extract_type_definition( + self, node: Node, source_bytes: bytes, definitions: list[TypeDefinition], is_exported: bool = False + ) -> None: + """Extract type definitions from a node.""" + # Handle export statements - unwrap to get the actual definition + if node.type == "export_statement": + for child in node.children: + if child.type in ( + "interface_declaration", + "type_alias_declaration", + "class_declaration", + "enum_declaration", + ): + self._extract_type_definition(child, source_bytes, definitions, is_exported=True) + return + + # Extract interface definitions + if node.type == "interface_declaration": + name_node = node.child_by_field_name("name") + if name_node: + # Look for preceding JSDoc comment + jsdoc = "" + prev_sibling = node.prev_named_sibling + if prev_sibling and prev_sibling.type == "comment": + comment_text = self.get_node_text(prev_sibling, source_bytes) + if comment_text.strip().startswith("/**"): + jsdoc = comment_text + "\n" + + definitions.append( + TypeDefinition( + name=self.get_node_text(name_node, source_bytes), + definition_type="interface", + source_code=jsdoc + self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + + # Extract type alias definitions + elif node.type == "type_alias_declaration": + name_node = node.child_by_field_name("name") + if name_node: + # Look for preceding JSDoc comment + jsdoc = "" + prev_sibling = node.prev_named_sibling + if prev_sibling and prev_sibling.type == "comment": + comment_text = self.get_node_text(prev_sibling, source_bytes) + if comment_text.strip().startswith("/**"): + jsdoc = comment_text + "\n" + + definitions.append( + TypeDefinition( + name=self.get_node_text(name_node, source_bytes), + definition_type="type", + source_code=jsdoc + self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + + # Extract enum definitions + elif node.type == "enum_declaration": + name_node = node.child_by_field_name("name") + if name_node: + # Look for preceding JSDoc comment + jsdoc = "" + prev_sibling = node.prev_named_sibling + if prev_sibling and prev_sibling.type == "comment": + comment_text = self.get_node_text(prev_sibling, source_bytes) + if comment_text.strip().startswith("/**"): + jsdoc = comment_text + "\n" + + definitions.append( + TypeDefinition( + name=self.get_node_text(name_node, source_bytes), + definition_type="enum", + source_code=jsdoc + self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + + # Extract class definitions (as types) + elif node.type == "class_declaration": + name_node = node.child_by_field_name("name") + if name_node: + # Look for preceding JSDoc comment + jsdoc = "" + prev_sibling = node.prev_named_sibling + if prev_sibling and prev_sibling.type == "comment": + comment_text = self.get_node_text(prev_sibling, source_bytes) + if comment_text.strip().startswith("/**"): + jsdoc = comment_text + "\n" + + definitions.append( + TypeDefinition( + name=self.get_node_text(name_node, source_bytes), + definition_type="class", + source_code=jsdoc + self.get_node_text(node, source_bytes), + start_line=node.start_point[0] + 1, + end_line=node.end_point[0] + 1, + is_exported=is_exported, + ) + ) + + +def get_analyzer_for_file(file_path: Path) -> TreeSitterAnalyzer: + """Get the appropriate TreeSitterAnalyzer for a file based on its extension. + + Args: + file_path: Path to the file. + + Returns: + TreeSitterAnalyzer configured for the file's language. + + """ + suffix = file_path.suffix.lower() + + if suffix in (".ts",): + return TreeSitterAnalyzer(TreeSitterLanguage.TYPESCRIPT) + if suffix in (".tsx",): + return TreeSitterAnalyzer(TreeSitterLanguage.TSX) + # Default to JavaScript for .js, .jsx, .mjs, .cjs + return TreeSitterAnalyzer(TreeSitterLanguage.JAVASCRIPT) diff --git a/codeflash/models/function_types.py b/codeflash/models/function_types.py index bea6672b0..dc51aaa92 100644 --- a/codeflash/models/function_types.py +++ b/codeflash/models/function_types.py @@ -7,7 +7,7 @@ from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Any, Optional from pydantic import Field from pydantic.dataclasses import dataclass @@ -61,6 +61,7 @@ class FunctionToOptimize: is_method: bool = False language: str = "python" doc_start_line: Optional[int] = None + metadata: Optional[dict[str, Any]] = Field(default=None) @property def top_level_parent_name(self) -> str: From 3601a18d608e35babaa53e095ad31f8cdc6399cc Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:54:14 +0000 Subject: [PATCH 02/57] style: auto-fix linting issues and resolve mypy type errors --- .../javascript/frameworks/react/analyzer.py | 102 +++++++++--------- .../javascript/frameworks/react/context.py | 38 +++---- .../javascript/frameworks/react/discovery.py | 81 ++++++++------ .../javascript/frameworks/react/profiler.py | 31 +++--- .../javascript/frameworks/react/testgen.py | 14 +-- codeflash/languages/javascript/parse.py | 20 ++-- codeflash/languages/javascript/support.py | 36 +++---- 7 files changed, 158 insertions(+), 164 deletions(-) diff --git a/codeflash/languages/javascript/frameworks/react/analyzer.py b/codeflash/languages/javascript/frameworks/react/analyzer.py index db87c22e6..2de39c802 100644 --- a/codeflash/languages/javascript/frameworks/react/analyzer.py +++ b/codeflash/languages/javascript/frameworks/react/analyzer.py @@ -44,9 +44,7 @@ class OptimizationOpportunity: # Patterns for expensive operations inside render body -EXPENSIVE_OPS_RE = re.compile( - r"\.(filter|map|sort|reduce|flatMap|find|findIndex|every|some)\s*\(" -) +EXPENSIVE_OPS_RE = re.compile(r"\.(filter|map|sort|reduce|flatMap|find|findIndex|every|some)\s*\(") INLINE_OBJECT_IN_JSX_RE = re.compile(r"=\{\s*\{") # ={{ ... }} in JSX INLINE_ARRAY_IN_JSX_RE = re.compile(r"=\{\s*\[") # ={[ ... ]} in JSX FUNCTION_DEF_RE = re.compile( @@ -57,9 +55,7 @@ class OptimizationOpportunity: USEMEMO_RE = re.compile(r"\buseMemo\s*\(") -def detect_optimization_opportunities( - source: str, component_info: ReactComponentInfo -) -> list[OptimizationOpportunity]: +def detect_optimization_opportunities(source: str, component_info: ReactComponentInfo) -> list[OptimizationOpportunity]: """Detect optimization opportunities in a React component.""" opportunities: list[OptimizationOpportunity] = [] lines = source.splitlines() @@ -81,46 +77,47 @@ def detect_optimization_opportunities( # Check if component should be wrapped in React.memo if not component_info.is_memoized: - opportunities.append(OptimizationOpportunity( - type=OpportunityType.MISSING_REACT_MEMO, - line=component_info.start_line, - description=f"Component '{component_info.function_name}' is not wrapped in React.memo(). " - "If it receives stable props, wrapping can prevent unnecessary re-renders.", - severity=OpportunitySeverity.MEDIUM, - )) + opportunities.append( + OptimizationOpportunity( + type=OpportunityType.MISSING_REACT_MEMO, + line=component_info.start_line, + description=f"Component '{component_info.function_name}' is not wrapped in React.memo(). " + "If it receives stable props, wrapping can prevent unnecessary re-renders.", + severity=OpportunitySeverity.MEDIUM, + ) + ) return opportunities -def _detect_inline_props( - lines: list[str], offset: int, opportunities: list[OptimizationOpportunity] -) -> None: +def _detect_inline_props(lines: list[str], offset: int, opportunities: list[OptimizationOpportunity]) -> None: """Detect inline object/array literals in JSX prop positions.""" for i, line in enumerate(lines): line_num = offset + i + 1 if INLINE_OBJECT_IN_JSX_RE.search(line): - opportunities.append(OptimizationOpportunity( - type=OpportunityType.INLINE_OBJECT_PROP, - line=line_num, - description="Inline object literal in JSX prop creates a new reference on every render. " - "Extract to useMemo or a module-level constant.", - severity=OpportunitySeverity.HIGH, - )) + opportunities.append( + OptimizationOpportunity( + type=OpportunityType.INLINE_OBJECT_PROP, + line=line_num, + description="Inline object literal in JSX prop creates a new reference on every render. " + "Extract to useMemo or a module-level constant.", + severity=OpportunitySeverity.HIGH, + ) + ) if INLINE_ARRAY_IN_JSX_RE.search(line): - opportunities.append(OptimizationOpportunity( - type=OpportunityType.INLINE_ARRAY_PROP, - line=line_num, - description="Inline array literal in JSX prop creates a new reference on every render. " - "Extract to useMemo or a module-level constant.", - severity=OpportunitySeverity.HIGH, - )) + opportunities.append( + OptimizationOpportunity( + type=OpportunityType.INLINE_ARRAY_PROP, + line=line_num, + description="Inline array literal in JSX prop creates a new reference on every render. " + "Extract to useMemo or a module-level constant.", + severity=OpportunitySeverity.HIGH, + ) + ) def _detect_missing_usecallback( - component_source: str, - lines: list[str], - offset: int, - opportunities: list[OptimizationOpportunity], + component_source: str, lines: list[str], offset: int, opportunities: list[OptimizationOpportunity] ) -> None: """Detect arrow functions or function expressions that could use useCallback.""" has_usecallback = bool(USECALLBACK_RE.search(component_source)) @@ -132,30 +129,31 @@ def _detect_missing_usecallback( if FUNCTION_DEF_RE.search(stripped) and "useCallback" not in stripped and "useMemo" not in stripped: # Skip if the component already uses useCallback extensively if not has_usecallback: - opportunities.append(OptimizationOpportunity( - type=OpportunityType.MISSING_USECALLBACK, - line=line_num, - description="Function defined inside render body creates a new reference on every render. " - "Wrap with useCallback() if passed as a prop to child components.", - severity=OpportunitySeverity.MEDIUM, - )) + opportunities.append( + OptimizationOpportunity( + type=OpportunityType.MISSING_USECALLBACK, + line=line_num, + description="Function defined inside render body creates a new reference on every render. " + "Wrap with useCallback() if passed as a prop to child components.", + severity=OpportunitySeverity.MEDIUM, + ) + ) def _detect_missing_usememo( - component_source: str, - lines: list[str], - offset: int, - opportunities: list[OptimizationOpportunity], + component_source: str, lines: list[str], offset: int, opportunities: list[OptimizationOpportunity] ) -> None: """Detect expensive computations that could benefit from useMemo.""" for i, line in enumerate(lines): line_num = offset + i + 1 stripped = line.strip() if EXPENSIVE_OPS_RE.search(stripped) and "useMemo" not in stripped: - opportunities.append(OptimizationOpportunity( - type=OpportunityType.MISSING_USEMEMO, - line=line_num, - description="Expensive array operation in render body runs on every render. " - "Wrap with useMemo() and specify dependencies.", - severity=OpportunitySeverity.HIGH, - )) + opportunities.append( + OptimizationOpportunity( + type=OpportunityType.MISSING_USEMEMO, + line=line_num, + description="Expensive array operation in render body runs on every render. " + "Wrap with useMemo() and specify dependencies.", + severity=OpportunitySeverity.HIGH, + ) + ) diff --git a/codeflash/languages/javascript/frameworks/react/context.py b/codeflash/languages/javascript/frameworks/react/context.py index b5dc2b871..0d53e5c8b 100644 --- a/codeflash/languages/javascript/frameworks/react/context.py +++ b/codeflash/languages/javascript/frameworks/react/context.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from tree_sitter import Node + from codeflash.languages.javascript.frameworks.react.analyzer import OptimizationOpportunity from codeflash.languages.javascript.frameworks.react.discovery import ReactComponentInfo from codeflash.languages.javascript.treesitter import TreeSitterAnalyzer @@ -73,24 +75,16 @@ def to_prompt_string(self) -> str: def extract_react_context( - component_info: ReactComponentInfo, - source: str, - analyzer: TreeSitterAnalyzer, - module_root: Path, + component_info: ReactComponentInfo, source: str, analyzer: TreeSitterAnalyzer, module_root: Path ) -> ReactContext: """Extract React-specific context for a component. Analyzes the component source to find props types, hooks, child components, and optimization opportunities. """ - from codeflash.languages.javascript.frameworks.react.analyzer import ( # noqa: PLC0415 - detect_optimization_opportunities, - ) + from codeflash.languages.javascript.frameworks.react.analyzer import detect_optimization_opportunities - context = ReactContext( - props_interface=component_info.props_type, - is_already_memoized=component_info.is_memoized, - ) + context = ReactContext(props_interface=component_info.props_type, is_already_memoized=component_info.is_memoized) # Extract hook usage details from the component source lines = source.splitlines() @@ -114,7 +108,7 @@ def extract_react_context( def _extract_hook_usages(component_source: str) -> list[HookUsage]: """Parse hook calls and their dependency arrays from component source.""" - import re # noqa: PLC0415 + import re hooks: list[HookUsage] = [] # Match useXxx( patterns @@ -124,7 +118,7 @@ def _extract_hook_usages(component_source: str) -> list[HookUsage]: hook_name = match.group(1) # Try to determine if there's a dependency array # Look for ], [ pattern after the hook call (simplified heuristic) - rest_of_line = component_source[match.end():] + rest_of_line = component_source[match.end() :] has_deps = False dep_count = 0 @@ -143,7 +137,7 @@ def _extract_hook_usages(component_source: str) -> list[HookUsage]: # Count items in the array (rough: count commas + 1 for non-empty) array_start = preceding.rfind("[") if array_start >= 0: - array_content = preceding[array_start + 1:-1].strip() + array_content = preceding[array_start + 1 : -1].strip() if array_content: dep_count = array_content.count(",") + 1 else: @@ -151,18 +145,14 @@ def _extract_hook_usages(component_source: str) -> list[HookUsage]: has_deps = True break - hooks.append(HookUsage( - name=hook_name, - has_dependency_array=has_deps, - dependency_count=dep_count, - )) + hooks.append(HookUsage(name=hook_name, has_dependency_array=has_deps, dependency_count=dep_count)) return hooks def _extract_child_components(component_source: str, analyzer: TreeSitterAnalyzer, full_source: str) -> list[str]: """Find child component names rendered in JSX.""" - import re # noqa: PLC0415 + import re # Match JSX tags that start with uppercase (React components) jsx_component_re = re.compile(r"<([A-Z][a-zA-Z0-9.]*)") @@ -177,7 +167,7 @@ def _extract_child_components(component_source: str, analyzer: TreeSitterAnalyze def _extract_context_subscriptions(component_source: str) -> list[str]: """Find React context subscriptions via useContext calls.""" - import re # noqa: PLC0415 + import re context_re = re.compile(r"\buseContext\s*\(\s*(\w+)") return [match.group(1) for match in context_re.finditer(component_source)] @@ -188,13 +178,13 @@ def _find_type_definition(type_name: str, source: str, analyzer: TreeSitterAnaly source_bytes = source.encode("utf-8") tree = analyzer.parse(source_bytes) - def search_node(node): + def search_node(node: Node) -> str | None: if node.type in ("interface_declaration", "type_alias_declaration"): name_node = node.child_by_field_name("name") if name_node: - name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8") + name = source_bytes[name_node.start_byte : name_node.end_byte].decode("utf-8") if name == type_name: - return source_bytes[node.start_byte:node.end_byte].decode("utf-8") + return source_bytes[node.start_byte : node.end_byte].decode("utf-8") for child in node.children: result = search_node(child) if result: diff --git a/codeflash/languages/javascript/frameworks/react/discovery.py b/codeflash/languages/javascript/frameworks/react/discovery.py index 194088885..9e39de817 100644 --- a/codeflash/languages/javascript/frameworks/react/discovery.py +++ b/codeflash/languages/javascript/frameworks/react/discovery.py @@ -8,12 +8,14 @@ import logging import re -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: + from tree_sitter import Node + from codeflash.languages.javascript.treesitter import FunctionNode, TreeSitterAnalyzer logger = logging.getLogger(__name__) @@ -23,13 +25,28 @@ HOOK_NAME_RE = re.compile(r"^use[A-Z]\w*$") # Built-in React hooks -BUILTIN_HOOKS = frozenset({ - "useState", "useEffect", "useContext", "useReducer", "useCallback", - "useMemo", "useRef", "useImperativeHandle", "useLayoutEffect", - "useInsertionEffect", "useDebugValue", "useDeferredValue", - "useTransition", "useId", "useSyncExternalStore", "useOptimistic", - "useActionState", "useFormStatus", -}) +BUILTIN_HOOKS = frozenset( + { + "useState", + "useEffect", + "useContext", + "useReducer", + "useCallback", + "useMemo", + "useRef", + "useImperativeHandle", + "useLayoutEffect", + "useInsertionEffect", + "useDebugValue", + "useDeferredValue", + "useTransition", + "useId", + "useSyncExternalStore", + "useOptimistic", + "useActionState", + "useFormStatus", + } +) class ComponentType(str, Enum): @@ -105,9 +122,7 @@ def find_react_components(source: str, file_path: Path, analyzer: TreeSitterAnal logger.debug("Skipping server component file: %s", file_path) return [] - functions = analyzer.find_functions( - source, include_methods=False, include_arrow_functions=True, require_name=True - ) + functions = analyzer.find_functions(source, include_methods=False, include_arrow_functions=True, require_name=True) components: list[ReactComponentInfo] = [] for func in functions: @@ -119,16 +134,18 @@ def find_react_components(source: str, file_path: Path, analyzer: TreeSitterAnal props_type = _extract_props_type(func, source, analyzer) is_memoized = _is_wrapped_in_memo(func, source) - components.append(ReactComponentInfo( - function_name=func.name, - component_type=comp_type, - uses_hooks=tuple(hooks_used), - returns_jsx=comp_type != ComponentType.HOOK and _function_returns_jsx(func, source, analyzer), - props_type=props_type, - is_memoized=is_memoized, - start_line=func.start_line, - end_line=func.end_line, - )) + components.append( + ReactComponentInfo( + function_name=func.name, + component_type=comp_type, + uses_hooks=tuple(hooks_used), + returns_jsx=comp_type != ComponentType.HOOK and _function_returns_jsx(func, source, analyzer), + props_type=props_type, + is_memoized=is_memoized, + start_line=func.start_line, + end_line=func.end_line, + ) + ) return components @@ -157,11 +174,14 @@ def _function_returns_jsx(func: FunctionNode, source: str, analyzer: TreeSitterA return False -def _node_contains_jsx(node) -> bool: +def _node_contains_jsx(node: Node) -> bool: """Recursively check if a tree-sitter node contains JSX.""" if node.type in ( - "jsx_element", "jsx_self_closing_element", "jsx_fragment", - "jsx_expression", "jsx_opening_element", + "jsx_element", + "jsx_self_closing_element", + "jsx_fragment", + "jsx_expression", + "jsx_opening_element", ): return True @@ -208,7 +228,7 @@ def _extract_props_type(func: FunctionNode, source: str, analyzer: TreeSitterAna # Get the type annotation node (skip the colon) for child in type_node.children: if child.type != ":": - return source_bytes[child.start_byte:child.end_byte].decode("utf-8") + return source_bytes[child.start_byte : child.end_byte].decode("utf-8") # Destructured params with type: { foo, bar }: Props if param.type == "object_pattern": # Look for next sibling that is a type_annotation @@ -216,7 +236,7 @@ def _extract_props_type(func: FunctionNode, source: str, analyzer: TreeSitterAna if next_sib and next_sib.type == "type_annotation": for child in next_sib.children: if child.type != ":": - return source_bytes[child.start_byte:child.end_byte].decode("utf-8") + return source_bytes[child.start_byte : child.end_byte].decode("utf-8") return None @@ -234,7 +254,7 @@ def _is_wrapped_in_memo(func: FunctionNode, source: str) -> bool: func_node = parent.child_by_field_name("function") if func_node: source_bytes = source.encode("utf-8") - func_text = source_bytes[func_node.start_byte:func_node.end_byte].decode("utf-8") + func_text = source_bytes[func_node.start_byte : func_node.end_byte].decode("utf-8") if func_text in ("React.memo", "memo"): return True parent = parent.parent @@ -242,10 +262,5 @@ def _is_wrapped_in_memo(func: FunctionNode, source: str) -> bool: # Also check for memo wrapping at the export level: # export default memo(MyComponent) name = func.name - memo_patterns = [ - f"React.memo({name})", - f"memo({name})", - f"React.memo({name},", - f"memo({name},", - ] + memo_patterns = [f"React.memo({name})", f"memo({name})", f"React.memo({name},", f"memo({name},"] return any(pattern in source for pattern in memo_patterns) diff --git a/codeflash/languages/javascript/frameworks/react/profiler.py b/codeflash/languages/javascript/frameworks/react/profiler.py index 9d273b70b..880793c11 100644 --- a/codeflash/languages/javascript/frameworks/react/profiler.py +++ b/codeflash/languages/javascript/frameworks/react/profiler.py @@ -16,6 +16,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from tree_sitter import Node + from codeflash.languages.javascript.treesitter import TreeSitterAnalyzer logger = logging.getLogger(__name__) @@ -81,7 +83,7 @@ def instrument_component_with_profiler(source: str, component_name: str, analyze def instrument_all_components_for_tracing(source: str, file_path: Path, analyzer: TreeSitterAnalyzer) -> str: """Instrument ALL components in a file for tracing/discovery mode.""" - from codeflash.languages.javascript.frameworks.react.discovery import find_react_components # noqa: PLC0415 + from codeflash.languages.javascript.frameworks.react.discovery import find_react_components components = find_react_components(source, file_path, analyzer) if not components: @@ -96,13 +98,13 @@ def instrument_all_components_for_tracing(source: str, file_path: Path, analyzer return result -def _find_component_function(root_node, component_name: str, source_bytes: bytes): +def _find_component_function(root_node: Node, component_name: str, source_bytes: bytes) -> Node | None: """Find the tree-sitter node for a named component function.""" # Check function declarations if root_node.type == "function_declaration": name_node = root_node.child_by_field_name("name") if name_node: - name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8") + name = source_bytes[name_node.start_byte : name_node.end_byte].decode("utf-8") if name == component_name: return root_node @@ -110,7 +112,7 @@ def _find_component_function(root_node, component_name: str, source_bytes: bytes if root_node.type == "variable_declarator": name_node = root_node.child_by_field_name("name") if name_node: - name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8") + name = source_bytes[name_node.start_byte : name_node.end_byte].decode("utf-8") if name == component_name: return root_node @@ -129,14 +131,17 @@ def _find_component_function(root_node, component_name: str, source_bytes: bytes return None -def _find_jsx_returns(func_node, source_bytes: bytes) -> list: +def _find_jsx_returns(func_node: Node, source_bytes: bytes) -> list[Node]: """Find all return statements that contain JSX within a function node.""" - returns = [] + returns: list[Node] = [] - def walk(node): + def walk(node: Node) -> None: # Don't descend into nested functions if node != func_node and node.type in ( - "function_declaration", "arrow_function", "function", "method_definition", + "function_declaration", + "arrow_function", + "function", + "method_definition", ): return @@ -154,11 +159,9 @@ def walk(node): return returns -def _contains_jsx(node) -> bool: +def _contains_jsx(node: Node) -> bool: """Check if a tree-sitter node contains JSX elements.""" - if node.type in ( - "jsx_element", "jsx_self_closing_element", "jsx_fragment", - ): + if node.type in ("jsx_element", "jsx_self_closing_element", "jsx_fragment"): return True for child in node.children: if _contains_jsx(child): @@ -166,7 +169,7 @@ def _contains_jsx(node) -> bool: return False -def _wrap_return_with_profiler(source: str, return_node, profiler_id: str, safe_name: str) -> str: +def _wrap_return_with_profiler(source: str, return_node: Node, profiler_id: str, safe_name: str) -> str: """Wrap a return statement's JSX with React.Profiler.""" source_bytes = source.encode("utf-8") @@ -238,7 +241,7 @@ def _ensure_react_import(source: str) -> str: if "from 'react'" in source or 'from "react"' in source: # React is imported but maybe not as the default. That's fine for JSX. # We need React.Profiler so add it - if "React" not in source.split("from")[0] if "from" in source else "": + if "React" not in source.split("from", maxsplit=1)[0] if "from" in source else "": return 'import React from "react";\n' + source return source return 'import React from "react";\n' + source diff --git a/codeflash/languages/javascript/frameworks/react/testgen.py b/codeflash/languages/javascript/frameworks/react/testgen.py index fd621b05e..4a3eeaf95 100644 --- a/codeflash/languages/javascript/frameworks/react/testgen.py +++ b/codeflash/languages/javascript/frameworks/react/testgen.py @@ -16,9 +16,7 @@ def build_react_testgen_context( - component_info: ReactComponentInfo, - react_context: ReactContext, - code_context: CodeContext, + component_info: ReactComponentInfo, react_context: ReactContext, code_context: CodeContext ) -> dict: """Assemble context dict for the React testgen LLM prompt.""" return { @@ -101,14 +99,12 @@ def post_process_react_tests(test_source: str, component_info: ReactComponentInf # Ensure act import if state updates are detected if "act(" in result and "import" in result and "act" not in result.split("from '@testing-library/react'")[0]: - result = result.replace( - "from '@testing-library/react'", - "act, " + "from '@testing-library/react'", - 1, - ) + result = result.replace("from '@testing-library/react'", "act, " + "from '@testing-library/react'", 1) # Ensure user-event import if user interactions are tested - if ("click" in result.lower() or "type" in result.lower() or "userEvent" in result) and "@testing-library/user-event" not in result: + if ( + "click" in result.lower() or "type" in result.lower() or "userEvent" in result + ) and "@testing-library/user-event" not in result: # Add user-event import after testing-library import result = re.sub( r"(import .+ from '@testing-library/react';?\n)", diff --git a/codeflash/languages/javascript/parse.py b/codeflash/languages/javascript/parse.py index 03aee9d38..820deeaec 100644 --- a/codeflash/languages/javascript/parse.py +++ b/codeflash/languages/javascript/parse.py @@ -34,9 +34,7 @@ # React Profiler render marker pattern # Format: !######REACT_RENDER:{component}:{phase}:{actualDuration}:{baseDuration}:{renderCount}######! -REACT_RENDER_MARKER_PATTERN = re.compile( - r"!######REACT_RENDER:([^:]+):([^:]+):([^:]+):([^:]+):(\d+)######!" -) +REACT_RENDER_MARKER_PATTERN = re.compile(r"!######REACT_RENDER:([^:]+):([^:]+):([^:]+):([^:]+):(\d+)######!") @dataclass(frozen=True) @@ -58,13 +56,15 @@ def parse_react_render_markers(stdout: str) -> list[RenderProfile]: profiles: list[RenderProfile] = [] for match in REACT_RENDER_MARKER_PATTERN.finditer(stdout): try: - profiles.append(RenderProfile( - component_name=match.group(1), - phase=match.group(2), - actual_duration_ms=float(match.group(3)), - base_duration_ms=float(match.group(4)), - render_count=int(match.group(5)), - )) + profiles.append( + RenderProfile( + component_name=match.group(1), + phase=match.group(2), + actual_duration_ms=float(match.group(3)), + base_duration_ms=float(match.group(4)), + render_count=int(match.group(5)), + ) + ) except (ValueError, IndexError) as e: logger.debug("Failed to parse React render marker: %s", e) return profiles diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index 45007caaa..7ec6bccd0 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -75,7 +75,7 @@ def dir_excludes(self) -> frozenset[str]: def get_framework_info(self, project_root: Path) -> FrameworkInfo: """Get cached framework info for the project.""" if self._cached_framework_root != project_root or self._cached_framework_info is None: - from codeflash.languages.javascript.frameworks.detector import detect_framework # noqa: PLC0415 + from codeflash.languages.javascript.frameworks.detector import detect_framework self._cached_framework_info = detect_framework(project_root) self._cached_framework_root = project_root @@ -120,14 +120,12 @@ def discover_functions( react_component_map: dict[str, Any] = {} project_root = file_path.parent # Will be refined by caller try: - from codeflash.languages.javascript.frameworks.react.discovery import ( # noqa: PLC0415 - classify_component, - ) + from codeflash.languages.javascript.frameworks.react.discovery import classify_component for func in tree_functions: comp_type = classify_component(func, source, analyzer) if comp_type is not None: - from codeflash.languages.javascript.frameworks.react.discovery import ( # noqa: PLC0415 + from codeflash.languages.javascript.frameworks.react.discovery import ( _extract_hooks_used, _is_wrapped_in_memo, ) @@ -473,13 +471,8 @@ def extract_code_context(self, function: FunctionToOptimize, project_root: Path, react_context_str = "" if function.metadata and function.metadata.get("is_react_component"): try: - from codeflash.languages.javascript.frameworks.react.discovery import ( # noqa: PLC0415 - ReactComponentInfo, - find_react_components, - ) - from codeflash.languages.javascript.frameworks.react.context import ( # noqa: PLC0415 - extract_react_context, - ) + from codeflash.languages.javascript.frameworks.react.context import extract_react_context + from codeflash.languages.javascript.frameworks.react.discovery import find_react_components components = find_react_components(source, function.file_path, analyzer) for comp in components: @@ -535,7 +528,7 @@ def _find_class_definition( source_bytes = source.encode("utf8") tree = analyzer.parse(source_bytes) - def find_class_node(node): + def find_class_node(node: Any) -> Any: """Recursively find a class declaration with the given name.""" if node.type in ("class_declaration", "class"): name_node = node.child_by_field_name("name") @@ -1953,10 +1946,9 @@ def _build_runtime_map( continue key = test_qualified_name + "#" + abs_path_str - parts = inv_id.iteration_id.split("_").__len__() # type: ignore[union-attr] - cur_invid = ( - inv_id.iteration_id.split("_")[0] if parts < 3 else "_".join(inv_id.iteration_id.split("_")[:-1]) - ) # type: ignore[union-attr] + iteration_id = inv_id.iteration_id or "" + parts = iteration_id.split("_").__len__() + cur_invid = iteration_id.split("_")[0] if parts < 3 else "_".join(iteration_id.split("_")[:-1]) match_key = key + "#" + cur_invid if match_key not in unique_inv_ids: unique_inv_ids[match_key] = 0 @@ -1967,7 +1959,7 @@ def _build_runtime_map( def compare_test_results( self, original_results_path: Path, candidate_results_path: Path, project_root: Path | None = None - ) -> tuple[bool, list]: + ) -> tuple[bool, list[Any]]: """Compare test results between original and candidate code. Args: @@ -2084,8 +2076,8 @@ def get_module_path(self, source_file: Path, project_root: Path, tests_root: Pat return rel_path except ValueError: # Fallback if paths are on different drives (Windows) - rel_path = source_file.relative_to(project_root) - return "../" + rel_path.with_suffix("").as_posix() + fallback_path = source_file.relative_to(project_root) + return "../" + fallback_path.with_suffix("").as_posix() def verify_requirements(self, project_root: Path, test_framework: str = "jest") -> tuple[bool, list[str]]: """Verify that all JavaScript requirements are met. @@ -2253,7 +2245,7 @@ def instrument_source_for_line_profiler( logger.warning("Failed to instrument source for line profiling: %s", e) return False - def parse_line_profile_results(self, line_profiler_output_file: Path) -> dict: + def parse_line_profile_results(self, line_profiler_output_file: Path) -> dict[str, Any]: from codeflash.languages.javascript.line_profiler import JavaScriptLineProfiler if line_profiler_output_file.exists(): @@ -2265,7 +2257,7 @@ def parse_line_profile_results(self, line_profiler_output_file: Path) -> dict: logger.warning("No line profiler output file found at %s", line_profiler_output_file) return {"timings": {}, "unit": 0, "str_out": ""} - def _format_js_line_profile_output(self, parsed_results: dict) -> str: + def _format_js_line_profile_output(self, parsed_results: dict[str, Any]) -> str: """Format JavaScript line profiler results for display.""" if not parsed_results.get("timings"): return "" From 07ab6186e5827b5891d509b74cb372e2642569d9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:54:55 +0000 Subject: [PATCH 03/57] fix: add missing type parameter to dict return type in testgen --- codeflash/languages/javascript/frameworks/react/testgen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codeflash/languages/javascript/frameworks/react/testgen.py b/codeflash/languages/javascript/frameworks/react/testgen.py index 4a3eeaf95..be09e858e 100644 --- a/codeflash/languages/javascript/frameworks/react/testgen.py +++ b/codeflash/languages/javascript/frameworks/react/testgen.py @@ -7,7 +7,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from codeflash.languages.base import CodeContext @@ -17,7 +17,7 @@ def build_react_testgen_context( component_info: ReactComponentInfo, react_context: ReactContext, code_context: CodeContext -) -> dict: +) -> dict[str, Any]: """Assemble context dict for the React testgen LLM prompt.""" return { "component_name": component_info.function_name, From 257ed8cf0f359c5cb8b0b0affd42de45e3246ac0 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 20 Feb 2026 08:26:42 +0530 Subject: [PATCH 04/57] add benchmarking --- codeflash/api/aiservice.py | 8 ++ codeflash/api/schemas.py | 17 +++ .../frameworks/react/benchmarking.py | 107 ++++++++++++++++++ codeflash/result/critic.py | 38 +++++++ codeflash/result/explanation.py | 8 ++ 5 files changed, 178 insertions(+) create mode 100644 codeflash/languages/javascript/frameworks/react/benchmarking.py diff --git a/codeflash/api/aiservice.py b/codeflash/api/aiservice.py index b8bc9454b..a144a9dd3 100644 --- a/codeflash/api/aiservice.py +++ b/codeflash/api/aiservice.py @@ -135,6 +135,8 @@ def optimize_code( is_async: bool = False, n_candidates: int = 5, is_numerical_code: bool | None = None, + is_react_component: bool = False, + react_context: str | None = None, ) -> list[OptimizedCandidate]: """Optimize the given code for performance by making a request to the Django endpoint. @@ -188,6 +190,12 @@ def optimize_code( if module_system: payload["module_system"] = module_system + # React-specific fields + if is_react_component: + payload["is_react_component"] = True + if react_context: + payload["react_context"] = react_context + # DEBUG: Print payload language field logger.debug( f"Sending optimize request with language='{payload['language']}' (type: {type(payload['language'])})" diff --git a/codeflash/api/schemas.py b/codeflash/api/schemas.py index 37e2c72a5..db64cb514 100644 --- a/codeflash/api/schemas.py +++ b/codeflash/api/schemas.py @@ -120,6 +120,10 @@ class OptimizeRequest: repo_name: str | None = None current_username: str | None = None + # === React-specific === + is_react_component: bool = False + react_context: str = "" + def to_payload(self) -> dict[str, Any]: """Convert to API payload dict, maintaining backward compatibility.""" payload = { @@ -150,6 +154,12 @@ def to_payload(self) -> dict[str, Any]: if self.language_info.module_system != ModuleSystem.UNKNOWN: payload["module_system"] = self.language_info.module_system.value + # React-specific fields + if self.is_react_component: + payload["is_react_component"] = True + if self.react_context: + payload["react_context"] = self.react_context + return payload @@ -187,6 +197,9 @@ class TestGenRequest: # === Metadata === codeflash_version: str = "" + # === React-specific === + is_react_component: bool = False + def to_payload(self) -> dict[str, Any]: """Convert to API payload dict, maintaining backward compatibility.""" payload = { @@ -218,6 +231,10 @@ def to_payload(self) -> dict[str, Any]: if self.language_info.module_system != ModuleSystem.UNKNOWN: payload["module_system"] = self.language_info.module_system.value + # React-specific fields + if self.is_react_component: + payload["is_react_component"] = True + return payload diff --git a/codeflash/languages/javascript/frameworks/react/benchmarking.py b/codeflash/languages/javascript/frameworks/react/benchmarking.py new file mode 100644 index 000000000..dfee93ad7 --- /dev/null +++ b/codeflash/languages/javascript/frameworks/react/benchmarking.py @@ -0,0 +1,107 @@ +"""React render benchmarking and comparison. + +Compares original vs optimized render profiles from React Profiler +instrumentation to quantify re-render reduction and render time improvement. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from codeflash.languages.javascript.parse import RenderProfile + + +@dataclass(frozen=True) +class RenderBenchmark: + """Comparison of original vs optimized render metrics.""" + + component_name: str + original_render_count: int + optimized_render_count: int + original_avg_duration_ms: float + optimized_avg_duration_ms: float + + @property + def render_count_reduction_pct(self) -> float: + """Percentage reduction in render count (0-100).""" + if self.original_render_count == 0: + return 0.0 + return ( + (self.original_render_count - self.optimized_render_count) + / self.original_render_count + * 100 + ) + + @property + def duration_reduction_pct(self) -> float: + """Percentage reduction in render duration (0-100).""" + if self.original_avg_duration_ms == 0: + return 0.0 + return ( + (self.original_avg_duration_ms - self.optimized_avg_duration_ms) + / self.original_avg_duration_ms + * 100 + ) + + @property + def render_speedup_x(self) -> float: + """Render time speedup factor (e.g., 2.5x means 2.5 times faster).""" + if self.optimized_avg_duration_ms == 0: + return 0.0 + return self.original_avg_duration_ms / self.optimized_avg_duration_ms + + +def compare_render_benchmarks( + original_profiles: list[RenderProfile], + optimized_profiles: list[RenderProfile], +) -> RenderBenchmark | None: + """Compare original and optimized render profiles. + + Aggregates render counts and durations across all render events + for the same component, then computes the benchmark comparison. + """ + if not original_profiles or not optimized_profiles: + return None + + # Use the first profile's component name + component_name = original_profiles[0].component_name + + # Aggregate original metrics + orig_count = max((p.render_count for p in original_profiles), default=0) + orig_durations = [p.actual_duration_ms for p in original_profiles] + orig_avg_duration = sum(orig_durations) / len(orig_durations) if orig_durations else 0.0 + + # Aggregate optimized metrics + opt_count = max((p.render_count for p in optimized_profiles), default=0) + opt_durations = [p.actual_duration_ms for p in optimized_profiles] + opt_avg_duration = sum(opt_durations) / len(opt_durations) if opt_durations else 0.0 + + return RenderBenchmark( + component_name=component_name, + original_render_count=orig_count, + optimized_render_count=opt_count, + original_avg_duration_ms=orig_avg_duration, + optimized_avg_duration_ms=opt_avg_duration, + ) + + +def format_render_benchmark_for_pr(benchmark: RenderBenchmark) -> str: + """Format render benchmark data for PR comment body.""" + lines = [ + "### React Render Performance", + "", + "| Metric | Before | After | Improvement |", + "|--------|--------|-------|-------------|", + f"| Renders | {benchmark.original_render_count} | {benchmark.optimized_render_count} " + f"| {benchmark.render_count_reduction_pct:.1f}% fewer |", + f"| Avg render time | {benchmark.original_avg_duration_ms:.2f}ms " + f"| {benchmark.optimized_avg_duration_ms:.2f}ms " + f"| {benchmark.duration_reduction_pct:.1f}% faster |", + ] + + if benchmark.render_speedup_x > 1: + lines.append(f"\nRender time improved **{benchmark.render_speedup_x:.1f}x**.") + + return "\n".join(lines) diff --git a/codeflash/result/critic.py b/codeflash/result/critic.py index 600c4a537..e04f01d50 100644 --- a/codeflash/result/critic.py +++ b/codeflash/result/critic.py @@ -21,6 +21,7 @@ class AcceptanceReason(Enum): RUNTIME = "runtime" THROUGHPUT = "throughput" CONCURRENCY = "concurrency" + RENDER_COUNT = "render_count" NONE = "none" @@ -208,3 +209,40 @@ def coverage_critic(original_code_coverage: CoverageData | None) -> bool: if original_code_coverage: return original_code_coverage.coverage >= COVERAGE_THRESHOLD return False + + +# Minimum render count reduction percentage to accept a React optimization +MIN_RENDER_COUNT_REDUCTION_PCT = 0.20 # 20% + + +def render_efficiency_critic( + original_render_count: int, + optimized_render_count: int, + original_render_duration: float, + optimized_render_duration: float, + best_render_count_until_now: int | None = None, +) -> bool: + """Evaluate whether a React optimization reduces re-renders or render time sufficiently. + + Accepts if: + - Render count is reduced by >= 20% + - OR render duration is reduced by >= MIN_IMPROVEMENT_THRESHOLD + - AND the candidate is the best seen so far + """ + if original_render_count == 0: + return False + + # Check render count reduction + count_reduction = (original_render_count - optimized_render_count) / original_render_count + count_improved = count_reduction >= MIN_RENDER_COUNT_REDUCTION_PCT + + # Check render duration reduction + duration_improved = False + if original_render_duration > 0: + duration_gain = (original_render_duration - optimized_render_duration) / original_render_duration + duration_improved = duration_gain > MIN_IMPROVEMENT_THRESHOLD + + # Check if this is the best candidate so far + is_best = best_render_count_until_now is None or optimized_render_count <= best_render_count_until_now + + return (count_improved or duration_improved) and is_best diff --git a/codeflash/result/explanation.py b/codeflash/result/explanation.py index f0aff73d0..55e0f31f7 100644 --- a/codeflash/result/explanation.py +++ b/codeflash/result/explanation.py @@ -30,6 +30,7 @@ class Explanation: original_concurrency_metrics: Optional[ConcurrencyMetrics] = None best_concurrency_metrics: Optional[ConcurrencyMetrics] = None acceptance_reason: AcceptanceReason = AcceptanceReason.RUNTIME + render_benchmark_markdown: Optional[str] = None @property def perf_improvement_line(self) -> str: @@ -37,6 +38,7 @@ def perf_improvement_line(self) -> str: AcceptanceReason.RUNTIME: "runtime", AcceptanceReason.THROUGHPUT: "throughput", AcceptanceReason.CONCURRENCY: "concurrency", + AcceptanceReason.RENDER_COUNT: "render count", AcceptanceReason.NONE: "", }.get(self.acceptance_reason, "") @@ -144,10 +146,16 @@ def __str__(self) -> str: else: performance_description = f"Runtime went down from {original_runtime_human} to {best_runtime_human} \n\n" + # Include React render benchmark if available + render_info = "" + if self.render_benchmark_markdown: + render_info = self.render_benchmark_markdown + "\n\n" + return ( f"Optimized {self.function_name} in {self.file_path}\n" f"{self.perf_improvement_line}\n" + performance_description + + render_info + (benchmark_info if benchmark_info else "") + self.raw_explanation_message + " \n\n" From 63fff653f1a8ffd5f365a2bdf4c8ec5ab75a21a8 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 20 Feb 2026 09:01:18 +0530 Subject: [PATCH 05/57] fix regex --- .../codeflash_benchmark/version.py | 2 +- .../javascript/frameworks/react/context.py | 2 +- .../javascript/frameworks/react/discovery.py | 9 +- codeflash/version.py | 2 +- tests/integration/test_react_e2e.py | 160 ++++++++++++++++ tests/react/__init__.py | 0 tests/react/fixtures/Counter.tsx | 21 +++ tests/react/fixtures/DataTable.tsx | 43 +++++ tests/react/fixtures/MemoizedList.tsx | 29 +++ tests/react/fixtures/ServerComponent.tsx | 17 ++ tests/react/fixtures/TaskList.tsx | 66 +++++++ tests/react/fixtures/UserCard.tsx | 22 +++ tests/react/fixtures/__init__.py | 0 tests/react/fixtures/package.json | 16 ++ tests/react/fixtures/useDebounce.ts | 17 ++ tests/react/test_analyzer.py | 137 ++++++++++++++ tests/react/test_benchmarking.py | 173 ++++++++++++++++++ tests/react/test_context.py | 157 ++++++++++++++++ tests/react/test_detector.py | 158 ++++++++++++++++ tests/react/test_discovery.py | 143 +++++++++++++++ tests/react/test_profiler.py | 111 +++++++++++ tests/react/test_testgen.py | 64 +++++++ uv.lock | 1 - 23 files changed, 1343 insertions(+), 7 deletions(-) create mode 100644 tests/integration/test_react_e2e.py create mode 100644 tests/react/__init__.py create mode 100644 tests/react/fixtures/Counter.tsx create mode 100644 tests/react/fixtures/DataTable.tsx create mode 100644 tests/react/fixtures/MemoizedList.tsx create mode 100644 tests/react/fixtures/ServerComponent.tsx create mode 100644 tests/react/fixtures/TaskList.tsx create mode 100644 tests/react/fixtures/UserCard.tsx create mode 100644 tests/react/fixtures/__init__.py create mode 100644 tests/react/fixtures/package.json create mode 100644 tests/react/fixtures/useDebounce.ts create mode 100644 tests/react/test_analyzer.py create mode 100644 tests/react/test_benchmarking.py create mode 100644 tests/react/test_context.py create mode 100644 tests/react/test_detector.py create mode 100644 tests/react/test_discovery.py create mode 100644 tests/react/test_profiler.py create mode 100644 tests/react/test_testgen.py diff --git a/codeflash-benchmark/codeflash_benchmark/version.py b/codeflash-benchmark/codeflash_benchmark/version.py index 18606e8d2..0a00478d2 100644 --- a/codeflash-benchmark/codeflash_benchmark/version.py +++ b/codeflash-benchmark/codeflash_benchmark/version.py @@ -1,2 +1,2 @@ # These version placeholders will be replaced by uv-dynamic-versioning during build. -__version__ = "0.3.0" +__version__ = "0.20.1.post127.dev0+c276ff32" diff --git a/codeflash/languages/javascript/frameworks/react/context.py b/codeflash/languages/javascript/frameworks/react/context.py index 0d53e5c8b..c05fc4003 100644 --- a/codeflash/languages/javascript/frameworks/react/context.py +++ b/codeflash/languages/javascript/frameworks/react/context.py @@ -112,7 +112,7 @@ def _extract_hook_usages(component_source: str) -> list[HookUsage]: hooks: list[HookUsage] = [] # Match useXxx( patterns - hook_pattern = re.compile(r"\b(use[A-Z]\w*)\s*\(") + hook_pattern = re.compile(r"\b(use[A-Z]\w*)\s*(?:<[^>]*>)?\s*\(") for match in hook_pattern.finditer(component_source): hook_name = match.group(1) diff --git a/codeflash/languages/javascript/frameworks/react/discovery.py b/codeflash/languages/javascript/frameworks/react/discovery.py index 9e39de817..4d1a1c552 100644 --- a/codeflash/languages/javascript/frameworks/react/discovery.py +++ b/codeflash/languages/javascript/frameworks/react/discovery.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) PASCAL_CASE_RE = re.compile(r"^[A-Z][a-zA-Z0-9]*$") -HOOK_CALL_RE = re.compile(r"\buse[A-Z]\w*\s*\(") +HOOK_CALL_RE = re.compile(r"\buse[A-Z]\w*\s*(?:<[^>]*>)?\s*\(") HOOK_NAME_RE = re.compile(r"^use[A-Z]\w*$") # Built-in React hooks @@ -198,12 +198,15 @@ def _node_contains_jsx(node: Node) -> bool: return False +HOOK_EXTRACT_RE = re.compile(r"\b(use[A-Z]\w*)\s*(?:<[^>]*>)?\s*\(") + + def _extract_hooks_used(function_source: str) -> list[str]: """Extract hook names called within a function body.""" hooks = [] seen = set() - for match in HOOK_CALL_RE.finditer(function_source): - hook_name = match.group(0).rstrip("( \t") + for match in HOOK_EXTRACT_RE.finditer(function_source): + hook_name = match.group(1) if hook_name not in seen: seen.add(hook_name) hooks.append(hook_name) diff --git a/codeflash/version.py b/codeflash/version.py index 5c0c09b55..0a00478d2 100644 --- a/codeflash/version.py +++ b/codeflash/version.py @@ -1,2 +1,2 @@ # These version placeholders will be replaced by uv-dynamic-versioning during build. -__version__ = "0.20.1" +__version__ = "0.20.1.post127.dev0+c276ff32" diff --git a/tests/integration/test_react_e2e.py b/tests/integration/test_react_e2e.py new file mode 100644 index 000000000..3addedb4b --- /dev/null +++ b/tests/integration/test_react_e2e.py @@ -0,0 +1,160 @@ +"""End-to-end integration test for the React optimization pipeline. + +Tests the full flow: framework detection → component discovery → context extraction +→ profiler marker parsing → benchmarking → critic evaluation. + +Note: This does not invoke the LLM or run actual Jest tests. It validates the +pipeline wiring by running each stage on fixture files and verifying outputs. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +FIXTURES_DIR = Path(__file__).parent.parent / "react" / "fixtures" + + +@pytest.fixture(autouse=True) +def clear_framework_cache(): + from codeflash.languages.javascript.frameworks.detector import detect_framework + + detect_framework.cache_clear() + yield + detect_framework.cache_clear() + + +class TestReactPipelineE2E: + def test_framework_detection_from_fixture(self): + from codeflash.languages.javascript.frameworks.detector import detect_framework + + info = detect_framework(FIXTURES_DIR) + assert info.name == "react" + assert info.react_version_major == 18 + assert info.has_testing_library is True + + def test_component_discovery(self): + from codeflash.languages.javascript.frameworks.react.discovery import find_react_components + from codeflash.languages.javascript.treesitter import TreeSitterAnalyzer + + analyzer = TreeSitterAnalyzer("tsx") + + # Counter.tsx — should find 1 function component + source = FIXTURES_DIR.joinpath("Counter.tsx").read_text(encoding="utf-8") + components = find_react_components(source, FIXTURES_DIR / "Counter.tsx", analyzer) + assert any(c.function_name == "Counter" for c in components) + + # ServerComponent.tsx — should be skipped (use server) + source = FIXTURES_DIR.joinpath("ServerComponent.tsx").read_text(encoding="utf-8") + components = find_react_components(source, FIXTURES_DIR / "ServerComponent.tsx", analyzer) + assert components == [] + + # useDebounce.ts — should be detected as hook, not component + ts_analyzer = TreeSitterAnalyzer("typescript") + source = FIXTURES_DIR.joinpath("useDebounce.ts").read_text(encoding="utf-8") + components = find_react_components(source, FIXTURES_DIR / "useDebounce.ts", ts_analyzer) + hooks = [c for c in components if c.component_type.value == "hook"] + assert len(hooks) == 1 + assert hooks[0].function_name == "useDebounce" + + def test_optimization_opportunity_detection(self): + from codeflash.languages.javascript.frameworks.react.analyzer import ( + OpportunityType, + detect_optimization_opportunities, + ) + from codeflash.languages.javascript.frameworks.react.discovery import ( + ComponentType, + ReactComponentInfo, + find_react_components, + ) + from codeflash.languages.javascript.treesitter import TreeSitterAnalyzer + + analyzer = TreeSitterAnalyzer("tsx") + + # DataTable has expensive operations without useMemo + source = FIXTURES_DIR.joinpath("DataTable.tsx").read_text(encoding="utf-8") + components = find_react_components(source, FIXTURES_DIR / "DataTable.tsx", analyzer) + data_table = [c for c in components if c.function_name == "DataTable"][0] + opps = detect_optimization_opportunities(source, data_table) + opp_types = [o.type for o in opps] + assert OpportunityType.MISSING_USEMEMO in opp_types + + # UserCard has inline objects in JSX + source = FIXTURES_DIR.joinpath("UserCard.tsx").read_text(encoding="utf-8") + components = find_react_components(source, FIXTURES_DIR / "UserCard.tsx", analyzer) + user_card = [c for c in components if c.function_name == "UserCard"][0] + opps = detect_optimization_opportunities(source, user_card) + opp_types = [o.type for o in opps] + assert OpportunityType.INLINE_OBJECT_PROP in opp_types + + def test_context_extraction(self): + from codeflash.languages.javascript.frameworks.react.context import extract_react_context + from codeflash.languages.javascript.frameworks.react.discovery import find_react_components + from codeflash.languages.javascript.treesitter import TreeSitterAnalyzer + + analyzer = TreeSitterAnalyzer("tsx") + source = FIXTURES_DIR.joinpath("TaskList.tsx").read_text(encoding="utf-8") + components = find_react_components(source, FIXTURES_DIR / "TaskList.tsx", analyzer) + task_list = [c for c in components if c.function_name == "TaskList"][0] + + context = extract_react_context(task_list, source, analyzer, FIXTURES_DIR) + assert len(context.hooks_used) > 0 + assert len(context.optimization_opportunities) > 0 + + prompt = context.to_prompt_string() + assert "useState" in prompt or "Hooks used" in prompt + + def test_profiler_marker_parsing(self): + from codeflash.languages.javascript.parse import parse_react_render_markers + + stdout = ( + "PASS src/TaskList.test.tsx\n" + "!######REACT_RENDER:TaskList:mount:25.3:40.1:1######!\n" + "!######REACT_RENDER:TaskList:update:5.2:40.1:5######!\n" + "!######REACT_RENDER:TaskList:update:4.8:40.1:10######!\n" + ) + profiles = parse_react_render_markers(stdout) + assert len(profiles) == 3 + assert profiles[0].component_name == "TaskList" + assert profiles[2].render_count == 10 + + def test_benchmarking_and_critic(self): + from codeflash.languages.javascript.frameworks.react.benchmarking import ( + compare_render_benchmarks, + format_render_benchmark_for_pr, + ) + from codeflash.languages.javascript.parse import RenderProfile + from codeflash.result.critic import render_efficiency_critic + + original = [ + RenderProfile("TaskList", "mount", 25.0, 40.0, 1), + RenderProfile("TaskList", "update", 5.0, 40.0, 25), + RenderProfile("TaskList", "update", 4.5, 40.0, 47), + ] + optimized = [ + RenderProfile("TaskList", "mount", 20.0, 35.0, 1), + RenderProfile("TaskList", "update", 2.0, 35.0, 3), + ] + + benchmark = compare_render_benchmarks(original, optimized) + assert benchmark is not None + assert benchmark.original_render_count == 47 + assert benchmark.optimized_render_count == 3 + assert benchmark.render_count_reduction_pct > 90 + + # Critic should accept this optimization + accepted = render_efficiency_critic( + original_render_count=benchmark.original_render_count, + optimized_render_count=benchmark.optimized_render_count, + original_render_duration=benchmark.original_avg_duration_ms, + optimized_render_duration=benchmark.optimized_avg_duration_ms, + ) + assert accepted is True + + # PR formatting + pr_output = format_render_benchmark_for_pr(benchmark) + assert "47" in pr_output + assert "3" in pr_output + assert "React Render Performance" in pr_output \ No newline at end of file diff --git a/tests/react/__init__.py b/tests/react/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/react/fixtures/Counter.tsx b/tests/react/fixtures/Counter.tsx new file mode 100644 index 000000000..0d9b5941d --- /dev/null +++ b/tests/react/fixtures/Counter.tsx @@ -0,0 +1,21 @@ +import React, { useState } from 'react'; + +interface CounterProps { + initialCount?: number; + label?: string; +} + +export function Counter({ initialCount = 0, label = 'Count' }: CounterProps) { + const [count, setCount] = useState(initialCount); + + const increment = () => setCount(c => c + 1); + const decrement = () => setCount(c => c - 1); + + return ( +
+ {label}: {count} + + +
+ ); +} diff --git a/tests/react/fixtures/DataTable.tsx b/tests/react/fixtures/DataTable.tsx new file mode 100644 index 000000000..4fdb273f7 --- /dev/null +++ b/tests/react/fixtures/DataTable.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +interface DataTableProps { + items: Array<{ id: number; name: string; value: number }>; + filterText: string; + sortBy: 'name' | 'value'; +} + +export function DataTable({ items, filterText, sortBy }: DataTableProps) { + // These expensive operations run on every render - should use useMemo + const filteredItems = items.filter(item => + item.name.toLowerCase().includes(filterText.toLowerCase()) + ); + + const sortedItems = filteredItems.sort((a, b) => { + if (sortBy === 'name') return a.name.localeCompare(b.name); + return a.value - b.value; + }); + + const total = sortedItems.reduce((sum, item) => sum + item.value, 0); + + return ( +
+ + + + + + + + + {sortedItems.map(item => ( + + + + + ))} + +
NameValue
{item.name}{item.value}
+
Total: {total}
+
+ ); +} diff --git a/tests/react/fixtures/MemoizedList.tsx b/tests/react/fixtures/MemoizedList.tsx new file mode 100644 index 000000000..8de7234d4 --- /dev/null +++ b/tests/react/fixtures/MemoizedList.tsx @@ -0,0 +1,29 @@ +import React, { memo } from 'react'; + +interface ListItemProps { + text: string; + isSelected: boolean; +} + +const ListItem = memo(function ListItem({ text, isSelected }: ListItemProps) { + return ( +
  • + {text} +
  • + ); +}); + +interface MemoizedListProps { + items: string[]; + selectedIndex: number; +} + +export const MemoizedList = memo(function MemoizedList({ items, selectedIndex }: MemoizedListProps) { + return ( +
      + {items.map((item, index) => ( + + ))} +
    + ); +}); diff --git a/tests/react/fixtures/ServerComponent.tsx b/tests/react/fixtures/ServerComponent.tsx new file mode 100644 index 000000000..180da48f1 --- /dev/null +++ b/tests/react/fixtures/ServerComponent.tsx @@ -0,0 +1,17 @@ +"use server"; + +interface ServerPageProps { + id: string; +} + +export async function ServerPage({ id }: ServerPageProps) { + const data = await fetch(`/api/data/${id}`); + const json = await data.json(); + + return ( +
    +

    {json.title}

    +

    {json.description}

    +
    + ); +} diff --git a/tests/react/fixtures/TaskList.tsx b/tests/react/fixtures/TaskList.tsx new file mode 100644 index 000000000..65d337534 --- /dev/null +++ b/tests/react/fixtures/TaskList.tsx @@ -0,0 +1,66 @@ +import React, { useState, useContext, useCallback } from 'react'; + +interface Task { + id: number; + title: string; + completed: boolean; + priority: 'low' | 'medium' | 'high'; +} + +interface TaskListProps { + tasks: Task[]; + onToggle: (id: number) => void; + onDelete: (id: number) => void; + filter: 'all' | 'active' | 'completed'; +} + +export function TaskList({ tasks, onToggle, onDelete, filter }: TaskListProps) { + const [sortBy, setSortBy] = useState<'title' | 'priority'>('title'); + + // Inline filtering and sorting without useMemo + const filteredTasks = tasks.filter(task => { + if (filter === 'active') return !task.completed; + if (filter === 'completed') return task.completed; + return true; + }); + + const sortedTasks = filteredTasks.sort((a, b) => { + if (sortBy === 'title') return a.title.localeCompare(b.title); + const priority = { low: 0, medium: 1, high: 2 }; + return priority[b.priority] - priority[a.priority]; + }); + + // Inline function defined in render body + const handleToggle = (id: number) => { + onToggle(id); + }; + + return ( +
    +
    + + +
    +
      + {sortedTasks.map(task => ( +
    • + handleToggle(task.id)} + /> + {task.title} + +
    • + ))} +
    +
    Total: {sortedTasks.length} tasks
    +
    + ); +} diff --git a/tests/react/fixtures/UserCard.tsx b/tests/react/fixtures/UserCard.tsx new file mode 100644 index 000000000..3e4bf08b8 --- /dev/null +++ b/tests/react/fixtures/UserCard.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +interface UserCardProps { + name: string; + email: string; + role: string; + onEdit: (email: string) => void; +} + +export function UserCard({ name, email, role, onEdit }: UserCardProps) { + return ( +
    +

    {name}

    +

    {email}

    + {role} + +
    + ); +} diff --git a/tests/react/fixtures/__init__.py b/tests/react/fixtures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/react/fixtures/package.json b/tests/react/fixtures/package.json new file mode 100644 index 000000000..76bea756d --- /dev/null +++ b/tests/react/fixtures/package.json @@ -0,0 +1,16 @@ +{ + "name": "test-react-project", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.0.0", + "@testing-library/jest-dom": "^6.0.0", + "typescript": "^5.0.0", + "jest": "^29.0.0", + "@types/react": "^18.0.0" + } +} diff --git a/tests/react/fixtures/useDebounce.ts b/tests/react/fixtures/useDebounce.ts new file mode 100644 index 000000000..0a45ef2ef --- /dev/null +++ b/tests/react/fixtures/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/tests/react/test_analyzer.py b/tests/react/test_analyzer.py new file mode 100644 index 000000000..4f9bad026 --- /dev/null +++ b/tests/react/test_analyzer.py @@ -0,0 +1,137 @@ +"""Tests for React optimization opportunity detection.""" + +from __future__ import annotations + +from codeflash.languages.javascript.frameworks.react.analyzer import ( + OpportunitySeverity, + OpportunityType, + detect_optimization_opportunities, +) +from codeflash.languages.javascript.frameworks.react.discovery import ( + ComponentType, + ReactComponentInfo, +) + + +def _make_component_info( + name: str = "TestComponent", + start_line: int = 1, + end_line: int = 20, + is_memoized: bool = False, +) -> ReactComponentInfo: + return ReactComponentInfo( + function_name=name, + component_type=ComponentType.FUNCTION, + returns_jsx=True, + is_memoized=is_memoized, + start_line=start_line, + end_line=end_line, + ) + + +class TestDetectInlineObjects: + def test_inline_style_prop(self): + source = 'function TestComponent() {\n return
    hello
    ;\n}' + info = _make_component_info(end_line=3) + opps = detect_optimization_opportunities(source, info) + types = [o.type for o in opps] + assert OpportunityType.INLINE_OBJECT_PROP in types + + def test_inline_array_prop(self): + source = "function TestComponent() {\n return - {/* Inefficient: inline style */} -
    +
    {stats.total} items | {stats.favorites} favorites | Avg score: {stats.avgScore.toFixed(1)} | {stats.categories} categories
    {Object.entries(categoryGroups).map(([category, categoryItems]) => (
    -

    +

    {category} ({categoryItems.length})

    {categoryItems.map(item => ( onToggleFavorite(id)} - onDelete={(id) => onDelete(id)} - // Inefficient: inline object creates new reference every render + onToggleFavorite={onToggleFavorite} + onDelete={onDelete} style={{ padding: '4px 8px', borderBottom: '1px solid #eee', diff --git a/codeflash/languages/javascript/instrument.py b/codeflash/languages/javascript/instrument.py index 579e0496c..fb884e907 100644 --- a/codeflash/languages/javascript/instrument.py +++ b/codeflash/languages/javascript/instrument.py @@ -107,6 +107,127 @@ def is_inside_string(code: str, pos: int) -> bool: return in_string +class JsxRenderCallTransformer: + """Transforms render(...) calls in React test code. + + React components are invoked via JSX in tests, not as direct function calls. + Tests use render() from @testing-library/react. This transformer + wraps those render() calls with codeflash instrumentation. + + Examples: + - render() -> codeflash.capturePerf('Comp', '1', () => render()) + - render(..., opts) -> codeflash.capturePerf('Comp', '1', () => render(..., opts)) + """ + + def __init__(self, function_to_optimize: FunctionToOptimize, capture_func: str) -> None: + self.function_to_optimize = function_to_optimize + self.func_name = function_to_optimize.function_name + self.qualified_name = function_to_optimize.qualified_name + self.capture_func = capture_func + self.invocation_counter = 0 + # Match render( followed by JSX containing the component name + # Captures: (whitespace)(await )?render( + self._render_pattern = re.compile( + rf"(\s*)(await\s+)?render\s*\(\s*<\s*{re.escape(self.func_name)}[\s>/]" + ) + + def transform(self, code: str) -> str: + """Transform all render() calls in the code.""" + result: list[str] = [] + pos = 0 + + while pos < len(code): + match = self._render_pattern.search(code, pos) + if not match: + result.append(code[pos:]) + break + + # Skip if inside a string literal + if is_inside_string(code, match.start()): + result.append(code[pos : match.end()]) + pos = match.end() + continue + + # Skip if already wrapped with codeflash + lookback = code[max(0, match.start() - 60) : match.start()] + if f"codeflash.{self.capture_func}(" in lookback: + result.append(code[pos : match.end()]) + pos = match.end() + continue + + # Add everything before the match + result.append(code[pos : match.start()]) + + leading_ws = match.group(1) + prefix = match.group(2) or "" # "await " or "" + + # Find the render( opening paren + render_call_text = code[match.start():] + render_paren_offset = render_call_text.index("(") + open_paren_pos = match.start() + render_paren_offset + + # Find the matching closing paren of render(...) + close_pos = self._find_matching_paren(code, open_paren_pos) + if close_pos == -1: + # Can't find matching paren, skip + result.append(code[match.start() : match.end()]) + pos = match.end() + continue + + # Extract the full render(...) arguments + render_args = code[open_paren_pos + 1 : close_pos - 1] + + # Check for trailing semicolon + end_pos = close_pos + while end_pos < len(code) and code[end_pos] in " \t": + end_pos += 1 + has_semicolon = end_pos < len(code) and code[end_pos] == ";" + if has_semicolon: + end_pos += 1 + + self.invocation_counter += 1 + line_id = str(self.invocation_counter) + semicolon = ";" if has_semicolon else "" + + # Wrap render(...) in a lambda: codeflash.capturePerf('name', 'id', () => render(...)) + transformed = ( + f"{leading_ws}{prefix}codeflash.{self.capture_func}('{self.qualified_name}', " + f"'{line_id}', () => render({render_args})){semicolon}" + ) + result.append(transformed) + pos = end_pos + + return "".join(result) + + def _find_matching_paren(self, code: str, open_paren_pos: int) -> int: + """Find the position after the closing paren for the given opening paren.""" + if open_paren_pos >= len(code) or code[open_paren_pos] != "(": + return -1 + + depth = 1 + pos = open_paren_pos + 1 + in_string = False + string_char = None + + while pos < len(code) and depth > 0: + char = code[pos] + if char in "\"'`" and (pos == 0 or code[pos - 1] != "\\"): + if not in_string: + in_string = True + string_char = char + elif char == string_char: + in_string = False + string_char = None + elif not in_string: + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + pos += 1 + + return pos if depth == 0 else -1 + + class StandaloneCallTransformer: """Transforms standalone func(...) calls in JavaScript test code. @@ -467,6 +588,31 @@ def transform_standalone_calls( return result, transformer.invocation_counter +def transform_jsx_render_calls( + code: str, function_to_optimize: FunctionToOptimize, capture_func: str, start_counter: int = 0 +) -> tuple[str, int]: + """Transform render(...) calls in React test code. + + This handles React components that are invoked via JSX rather than direct function + calls. When a component is used as in a render() call, this wraps + the entire render() with codeflash instrumentation. + + Args: + code: The test code to transform. + function_to_optimize: The React component being tested. + capture_func: The capture function to use ('capture' or 'capturePerf'). + start_counter: Starting value for the invocation counter. + + Returns: + Tuple of (transformed code, final counter value). + + """ + transformer = JsxRenderCallTransformer(function_to_optimize=function_to_optimize, capture_func=capture_func) + transformer.invocation_counter = start_counter + result = transformer.transform(code) + return result, transformer.invocation_counter + + class ExpectCallTransformer: """Transforms expect(func(...)).assertion() calls in JavaScript test code. @@ -1038,6 +1184,21 @@ def inject_profiling_into_existing_js_test( return True, instrumented_code +def _is_jsx_component_usage(code: str, func_name: str) -> bool: + """Check if a function is used as a JSX component in render() calls. + + Returns True if the code contains patterns like render( or , indicating the function is a React component + rendered via JSX rather than called directly. + """ + # Check for JSX usage: or + jsx_pattern = rf"<\s*{re.escape(func_name)}[\s>/]" + if not re.search(jsx_pattern, code): + return False + # Also verify there's a render() call (from @testing-library/react or similar) + return bool(re.search(r"\brender\s*\(", code)) + + def _is_function_used_in_test(code: str, func_name: str) -> bool: """Check if a function is imported or used in the test code. @@ -1122,6 +1283,8 @@ def _instrument_js_test_code( # Choose capture function based on mode capture_func = "capturePerf" if mode == TestingMode.PERFORMANCE else "capture" + # Save code state before transforms to detect if any calls were instrumented + code_before_transforms = code # Transform React render calls: render(React.createElement(Component, ...)) # Do this first so expect/standalone transforms don't interfere with render patterns code, render_counter = transform_render_calls( @@ -1140,10 +1303,21 @@ def _instrument_js_test_code( # Transform standalone calls (not inside expect wrappers) # Continue counter from expect transformer to ensure unique IDs - code, _final_counter = transform_standalone_calls( + code, final_counter = transform_standalone_calls( code=code, function_to_optimize=function_to_optimize, capture_func=capture_func, start_counter=expect_counter ) + # If no direct function calls were instrumented, check for JSX usage (React components). + # React components are invoked via JSX () in render() calls, not as + # direct function calls. Detect this pattern and wrap render() calls instead. + if code == code_before_transforms and _is_jsx_component_usage(code_before_transforms, function_to_optimize.function_name): + code, _jsx_counter = transform_jsx_render_calls( + code=code, + function_to_optimize=function_to_optimize, + capture_func=capture_func, + start_counter=final_counter, + ) + return code diff --git a/codeflash/languages/javascript/test_runner.py b/codeflash/languages/javascript/test_runner.py index 3a193602b..896c07f9f 100644 --- a/codeflash/languages/javascript/test_runner.py +++ b/codeflash/languages/javascript/test_runner.py @@ -590,6 +590,39 @@ def _uses_ts_jest(project_root: Path) -> bool: return False +_ENV_VAR_RE = __import__("re").compile(r"""([A-Z_][A-Z0-9_]*)=(?:'([^']*)'|"([^"]*)"|(\S+))""") + + +def _extract_env_vars_from_test_script(project_root: Path) -> dict[str, str]: + """Extract environment variables from the project's jest/test scripts in package.json. + + Parses scripts like: TZ=UTC TS_NODE_COMPILER_OPTIONS='{"allowJs": false}' jest + """ + pkg_json = project_root / "package.json" + if not pkg_json.exists(): + return {} + + try: + pkg = json.loads(pkg_json.read_text(encoding="utf-8")) + scripts = pkg.get("scripts", {}) + except Exception: + return {} + + # Look for jest-related scripts: test, testunit, .testunit:jest, etc. + script_keys = [k for k in scripts if "jest" in scripts[k].lower() or k in ("test", "testunit")] + env_vars: dict[str, str] = {} + for key in script_keys: + script = scripts[key] + for match in _ENV_VAR_RE.finditer(script): + name = match.group(1) + value = match.group(2) or match.group(3) or match.group(4) or "" + env_vars[name] = value + + if env_vars: + logger.debug(f"Extracted env vars from package.json scripts: {list(env_vars.keys())}") + return env_vars + + def _configure_esm_environment(jest_env: dict[str, str], project_root: Path) -> None: """Configure environment variables for ES Module support in Jest. @@ -751,6 +784,12 @@ def run_jest_behavioral_tests( # Configure ESM support if project uses ES Modules _configure_esm_environment(jest_env, effective_cwd) + # Extract env vars from project's test scripts (e.g. TZ, TS_NODE_COMPILER_OPTIONS) + script_env_vars = _extract_env_vars_from_test_script(effective_cwd) + for key, value in script_env_vars.items(): + if key not in jest_env: + jest_env[key] = value + # Increase Node.js heap size for large TypeScript projects # Default heap is often not enough for monorepos with many dependencies existing_node_options = jest_env.get("NODE_OPTIONS", "") @@ -1014,6 +1053,12 @@ def run_jest_benchmarking_tests( # Configure ESM support if project uses ES Modules _configure_esm_environment(jest_env, effective_cwd) + # Extract env vars from project's test scripts (e.g. TZ, TS_NODE_COMPILER_OPTIONS) + script_env_vars = _extract_env_vars_from_test_script(effective_cwd) + for key, value in script_env_vars.items(): + if key not in jest_env: + jest_env[key] = value + # Increase Node.js heap size for large TypeScript projects existing_node_options = jest_env.get("NODE_OPTIONS", "") if "--max-old-space-size" not in existing_node_options: @@ -1160,6 +1205,12 @@ def run_jest_line_profile_tests( # Configure ESM support if project uses ES Modules _configure_esm_environment(jest_env, effective_cwd) + # Extract env vars from project's test scripts (e.g. TZ, TS_NODE_COMPILER_OPTIONS) + script_env_vars = _extract_env_vars_from_test_script(effective_cwd) + for key, value in script_env_vars.items(): + if key not in jest_env: + jest_env[key] = value + # Increase Node.js heap size for large TypeScript projects existing_node_options = jest_env.get("NODE_OPTIONS", "") if "--max-old-space-size" not in existing_node_options: diff --git a/packages/codeflash/runtime/loop-runner.js b/packages/codeflash/runtime/loop-runner.js index fc0b88f32..5866ae406 100644 --- a/packages/codeflash/runtime/loop-runner.js +++ b/packages/codeflash/runtime/loop-runner.js @@ -117,18 +117,13 @@ function findJestRunnerRecursive(nodeModulesPath, maxDepth = 5) { function resolveJestRunner() { const monorepoMarkers = ['yarn.lock', 'pnpm-workspace.yaml', 'lerna.json', 'package-lock.json']; - // Walk up from cwd to find all potential node_modules locations + // Walk up from cwd to find jest-runner, checking the project's own + // node_modules first. In monorepos, the workspace package (cwd) may have + // a different jest-runner version than the monorepo root. The project's + // version takes priority since it matches the Jest config being used. let currentDir = process.cwd(); const visitedDirs = new Set(); - // If Python detected a monorepo root, check there first - const monorepoRoot = process.env.CODEFLASH_MONOREPO_ROOT; - if (monorepoRoot && !visitedDirs.has(monorepoRoot)) { - visitedDirs.add(monorepoRoot); - const result = findJestRunnerRecursive(path.join(monorepoRoot, 'node_modules')); - if (result) return result; - } - while (currentDir !== path.dirname(currentDir)) { if (visitedDirs.has(currentDir)) break; visitedDirs.add(currentDir); @@ -145,6 +140,13 @@ function resolveJestRunner() { currentDir = path.dirname(currentDir); } + // Fallback: check monorepo root if Python detected one and we haven't visited it yet + const monorepoRoot = process.env.CODEFLASH_MONOREPO_ROOT; + if (monorepoRoot && !visitedDirs.has(monorepoRoot)) { + const result = findJestRunnerRecursive(path.join(monorepoRoot, 'node_modules')); + if (result) return result; + } + throw new Error( 'jest-runner not found. Please install jest-runner in your project: npm install --save-dev jest-runner' ); From 98eb58ef980e3b79a405b6a58c7c1a4bd837043e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:18:27 +0000 Subject: [PATCH 43/57] style: auto-fix linting issues --- codeflash/languages/javascript/instrument.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/codeflash/languages/javascript/instrument.py b/codeflash/languages/javascript/instrument.py index fb884e907..96ed550c6 100644 --- a/codeflash/languages/javascript/instrument.py +++ b/codeflash/languages/javascript/instrument.py @@ -117,6 +117,7 @@ class JsxRenderCallTransformer: Examples: - render() -> codeflash.capturePerf('Comp', '1', () => render()) - render(..., opts) -> codeflash.capturePerf('Comp', '1', () => render(..., opts)) + """ def __init__(self, function_to_optimize: FunctionToOptimize, capture_func: str) -> None: @@ -127,9 +128,7 @@ def __init__(self, function_to_optimize: FunctionToOptimize, capture_func: str) self.invocation_counter = 0 # Match render( followed by JSX containing the component name # Captures: (whitespace)(await )?render( - self._render_pattern = re.compile( - rf"(\s*)(await\s+)?render\s*\(\s*<\s*{re.escape(self.func_name)}[\s>/]" - ) + self._render_pattern = re.compile(rf"(\s*)(await\s+)?render\s*\(\s*<\s*{re.escape(self.func_name)}[\s>/]") def transform(self, code: str) -> str: """Transform all render() calls in the code.""" @@ -162,7 +161,7 @@ def transform(self, code: str) -> str: prefix = match.group(2) or "" # "await " or "" # Find the render( opening paren - render_call_text = code[match.start():] + render_call_text = code[match.start() :] render_paren_offset = render_call_text.index("(") open_paren_pos = match.start() + render_paren_offset @@ -1310,12 +1309,11 @@ def _instrument_js_test_code( # If no direct function calls were instrumented, check for JSX usage (React components). # React components are invoked via JSX () in render() calls, not as # direct function calls. Detect this pattern and wrap render() calls instead. - if code == code_before_transforms and _is_jsx_component_usage(code_before_transforms, function_to_optimize.function_name): + if code == code_before_transforms and _is_jsx_component_usage( + code_before_transforms, function_to_optimize.function_name + ): code, _jsx_counter = transform_jsx_render_calls( - code=code, - function_to_optimize=function_to_optimize, - capture_func=capture_func, - start_counter=final_counter, + code=code, function_to_optimize=function_to_optimize, capture_func=capture_func, start_counter=final_counter ) return code From f95dc375f97d72acd713cf0413ad70a8e2f24519 Mon Sep 17 00:00:00 2001 From: ali Date: Wed, 25 Feb 2026 22:34:35 +0200 Subject: [PATCH 44/57] benchmarking for react components with samples --- .../js/code_to_optimize_react/jest.config.ts | 3 +- codeflash/languages/javascript/test_runner.py | 1 + packages/codeflash/runtime/capture.js | 129 +++------- packages/codeflash/runtime/index.js | 130 +++++------ .../runtime/react-benchmark/Benchmark.jsx | 220 ++++++++++++++++++ .../codeflash/runtime/react-benchmark/math.js | 24 ++ .../codeflash/runtime/react-benchmark/run.js | 38 +++ .../runtime/react-benchmark/timing.js | 16 ++ packages/codeflash/runtime/utils.js | 21 ++ 9 files changed, 420 insertions(+), 162 deletions(-) create mode 100644 packages/codeflash/runtime/react-benchmark/Benchmark.jsx create mode 100644 packages/codeflash/runtime/react-benchmark/math.js create mode 100644 packages/codeflash/runtime/react-benchmark/run.js create mode 100644 packages/codeflash/runtime/react-benchmark/timing.js create mode 100644 packages/codeflash/runtime/utils.js diff --git a/code_to_optimize/js/code_to_optimize_react/jest.config.ts b/code_to_optimize/js/code_to_optimize_react/jest.config.ts index e59623661..5b58b0fbe 100644 --- a/code_to_optimize/js/code_to_optimize_react/jest.config.ts +++ b/code_to_optimize/js/code_to_optimize_react/jest.config.ts @@ -28,8 +28,7 @@ const config: Config = { ] ], transform: { - '^.+\\.tsx?$': ['ts-jest', { - useESM: false, + "^.+\\\\.(ts | tsx)$": ['ts-jest', { tsconfig: 'tsconfig.json' }] } diff --git a/codeflash/languages/javascript/test_runner.py b/codeflash/languages/javascript/test_runner.py index 896c07f9f..6eadd13ac 100644 --- a/codeflash/languages/javascript/test_runner.py +++ b/codeflash/languages/javascript/test_runner.py @@ -842,6 +842,7 @@ def run_jest_behavioral_tests( wall_clock_ns = time.perf_counter_ns() - start_time_ns logger.debug(f"Jest behavioral tests completed in {wall_clock_ns / 1e9:.2f}s") + print(result.stdout) return result_file_path, result, coverage_json_path, None diff --git a/packages/codeflash/runtime/capture.js b/packages/codeflash/runtime/capture.js index 35b6bb53f..4db716367 100644 --- a/packages/codeflash/runtime/capture.js +++ b/packages/codeflash/runtime/capture.js @@ -29,26 +29,21 @@ const fs = require('fs'); const path = require('path'); const Database = require('better-sqlite3'); +const { requireFromRoot } = require("./utils") // Load the codeflash serializer for robust value serialization const serializer = require('./serializer'); -// Lazy-cached React instance resolved from the user's project (not from codeflash's -// own dependencies). We resolve from process.cwd() so Node finds the react package -// in the project's node_modules rather than looking inside codeflash's package tree. -let _cachedReact = null; + function _getReact() { - if (_cachedReact) return _cachedReact; try { - const reactPath = require.resolve('react', { paths: [process.cwd()] }); - _cachedReact = require(reactPath); + return requireFromRoot("react"); } catch (e) { throw new Error( `codeflash: Could not resolve 'react' from project root (${process.cwd()}). ` + `Ensure react is installed in your project: npm install react` ); } - return _cachedReact; } // Try to load better-sqlite3, fall back to JSON if not available @@ -1082,106 +1077,50 @@ function captureRender(funcName, lineId, renderFn, Component, ...createElementAr * @throws {Error} - Re-throws any error from rendering */ function captureRenderPerf(funcName, lineId, renderFn, Component, ...createElementArgs) { - const shouldLoop = getPerfLoopCount() > 1 && !checkSharedTimeLimit(); + const runBenchmark = require('./react-benchmark/run'); - const { testModulePath, testClassName, testFunctionName, safeModulePath, safeTestFunctionName } = _getTestContext(); + const { testClassName, safeModulePath, safeTestFunctionName } = _getTestContext(); const invocationKey = `${safeModulePath}:${testClassName}:${safeTestFunctionName}:${funcName}:${lineId}`; - // Check if we've already completed all loops for this invocation - const peekLoopIndex = (sharedPerfState.invocationLoopCounts[invocationKey] || 0); - const currentBatch = parseInt(process.env.CODEFLASH_PERF_CURRENT_BATCH || '1', 10); - const nextGlobalIndex = (currentBatch - 1) * getPerfBatchSize() + peekLoopIndex + 1; - - const React = _getReact(); - - if (shouldLoop && nextGlobalIndex > getPerfLoopCount()) { - // All loops completed, just render once for test assertions - const element = React.createElement(Component, ...createElementArgs); - return renderFn(element); - } - - let lastReturnValue; - let lastError = null; - - const hasExternalLoopRunner = process.env.CODEFLASH_PERF_CURRENT_BATCH !== undefined; - const batchSize = hasExternalLoopRunner ? 1 : (shouldLoop ? getPerfLoopCount() : 1); - - if (!sharedPerfState.invocationRuntimes[invocationKey]) { - sharedPerfState.invocationRuntimes[invocationKey] = []; - } - const runtimes = sharedPerfState.invocationRuntimes[invocationKey]; - const getStabilityWindow = () => Math.max(getPerfMinLoops(), Math.ceil(runtimes.length * STABILITY_WINDOW_SIZE)); - - for (let batchIndex = 0; batchIndex < batchSize; batchIndex++) { - if (!hasExternalLoopRunner && shouldLoop && checkSharedTimeLimit()) { - break; - } - if (!hasExternalLoopRunner && getPerfStabilityCheck() && sharedPerfState.stableInvocations[invocationKey]) { - break; - } - - const loopIndex = getInvocationLoopIndex(invocationKey); - - const totalIterations = getTotalIterations(invocationKey); - if (!hasExternalLoopRunner && totalIterations > getPerfLoopCount()) { - break; - } - - const testId = `${safeModulePath}:${testClassName}:${safeTestFunctionName}:${lineId}:${loopIndex}`; - const invocationIndex = getInvocationIndex(testId); - const invocationId = `${lineId}_${invocationIndex}`; - const testStdoutTag = `${safeModulePath}:${testClassName ? testClassName + '.' : ''}${safeTestFunctionName}:${funcName}:${loopIndex}:${invocationId}`; + const numSamples = getPerfLoopCount() > 1 ? getPerfLoopCount() : 50; - let durationNs; - try { - // Unmount previous render to keep DOM clean between iterations - if (lastReturnValue && lastReturnValue.unmount) { - lastReturnValue.unmount(); - } + // createElementArgs matches React.createElement signature: (props, ...children) + const props = createElementArgs[0] || {}; - const element = React.createElement(Component, ...createElementArgs); - const startTime = getTimeNs(); - lastReturnValue = renderFn(element); - const endTime = getTimeNs(); - durationNs = getDurationNs(startTime, endTime); + const MS_TO_NS = 1e6; - lastError = null; - } catch (e) { - durationNs = 0; - lastError = e; - } - - console.log(`!######${testStdoutTag}:${durationNs}######!`); - - sharedPerfState.totalLoopsCompleted++; - - if (durationNs > 0) { - runtimes.push(durationNs / 1000); - } + return runBenchmark({ + component: Component, + props, + samples: numSamples, + type: 'mount', + }).then((results) => { + // Emit perf markers for each sample so the Python parser can collect timings + for (let i = 0; i < results.samples.length; i++) { + const sample = results.samples[i]; + const durationNs = Math.round(sample.elapsed * MS_TO_NS); - if (!hasExternalLoopRunner && getPerfStabilityCheck() && runtimes.length >= getPerfMinLoops()) { - const window = getStabilityWindow(); - if (shouldStopStability(runtimes, window, getPerfMinLoops())) { - sharedPerfState.stableInvocations[invocationKey] = true; - break; - } - } + const loopIndex = getInvocationLoopIndex(invocationKey); + const testId = `${safeModulePath}:${testClassName}:${safeTestFunctionName}:${lineId}:${loopIndex}`; + const invocationIndex = getInvocationIndex(testId); + const invocationId = `${lineId}_${invocationIndex}`; + const testStdoutTag = `${safeModulePath}:${testClassName ? testClassName + '.' : ''}${safeTestFunctionName}:${funcName}:${loopIndex}:${invocationId}`; - if (!hasExternalLoopRunner && lastError) { - break; + console.log(`!######${testStdoutTag}:${durationNs}######!`); + sharedPerfState.totalLoopsCompleted++; } - } - - if (lastError) throw lastError; - // If we never executed (e.g., hit loop limit on first iteration), render once for assertion - if (lastReturnValue === undefined && !lastError) { + // Render once more so the test's own assertions (e.g. screen.getByText) still pass + const React = _getReact(); const element = React.createElement(Component, ...createElementArgs); return renderFn(element); - } - - return lastReturnValue; + }).catch(() => { + // If benchmark fails, render once so test assertions can still run + const React = _getReact(); + const element = React.createElement(Component, ...createElementArgs); + return renderFn(element); + }); } /** diff --git a/packages/codeflash/runtime/index.js b/packages/codeflash/runtime/index.js index 00adf085a..bf2fdbbf5 100644 --- a/packages/codeflash/runtime/index.js +++ b/packages/codeflash/runtime/index.js @@ -16,80 +16,80 @@ * import { capture, capturePerf } from 'codeflash'; */ -'use strict'; +"use strict"; // Main capture functions (instrumentation) -const capture = require('./capture'); +const capture = require("./capture"); // Serialization utilities -const serializer = require('./serializer'); +const serializer = require("./serializer"); // Comparison utilities -const comparator = require('./comparator'); +const comparator = require("./comparator"); // Result comparison (used by CLI) -const compareResults = require('./compare-results'); +const compareResults = require("./compare-results"); // Re-export all public APIs module.exports = { - // === Main Instrumentation API === - capture: capture.capture, - capturePerf: capture.capturePerf, - - captureRender: capture.captureRender, - captureRenderPerf: capture.captureRenderPerf, - - captureMultiple: capture.captureMultiple, - - // === Test Lifecycle === - writeResults: capture.writeResults, - clearResults: capture.clearResults, - getResults: capture.getResults, - setTestName: capture.setTestName, - initDatabase: capture.initDatabase, - resetInvocationCounters: capture.resetInvocationCounters, - - // === Serialization === - serialize: serializer.serialize, - deserialize: serializer.deserialize, - getSerializerType: serializer.getSerializerType, - safeSerialize: capture.safeSerialize, - safeDeserialize: capture.safeDeserialize, - - // === Comparison === - comparator: comparator.comparator, - createComparator: comparator.createComparator, - strictComparator: comparator.strictComparator, - looseComparator: comparator.looseComparator, - isClose: comparator.isClose, - - // === Result Comparison (CLI helpers) === - readTestResults: compareResults.readTestResults, - compareResults: compareResults.compareResults, - compareBuffers: compareResults.compareBuffers, - - // === Utilities === - getInvocationIndex: capture.getInvocationIndex, - sanitizeTestId: capture.sanitizeTestId, - - // === Constants === - LOOP_INDEX: capture.LOOP_INDEX, - OUTPUT_FILE: capture.OUTPUT_FILE, - TEST_ITERATION: capture.TEST_ITERATION, - - // === Batch Looping Control (used by loop-runner) === - incrementBatch: capture.incrementBatch, - getCurrentBatch: capture.getCurrentBatch, - checkSharedTimeLimit: capture.checkSharedTimeLimit, - // Getter functions for dynamic env var reading (not constants) - getPerfBatchSize: capture.getPerfBatchSize, - getPerfLoopCount: capture.getPerfLoopCount, - getPerfMinLoops: capture.getPerfMinLoops, - getPerfTargetDurationMs: capture.getPerfTargetDurationMs, - getPerfStabilityCheck: capture.getPerfStabilityCheck, - getPerfCurrentBatch: capture.getPerfCurrentBatch, - - // === Feature Detection === - hasV8: serializer.hasV8, - hasMsgpack: serializer.hasMsgpack, + // === Main Instrumentation API === + capture: capture.capture, + capturePerf: capture.capturePerf, + + captureRender: capture.captureRender, + captureRenderPerf: capture.captureRenderPerf, + + captureMultiple: capture.captureMultiple, + + // === Test Lifecycle === + writeResults: capture.writeResults, + clearResults: capture.clearResults, + getResults: capture.getResults, + setTestName: capture.setTestName, + initDatabase: capture.initDatabase, + resetInvocationCounters: capture.resetInvocationCounters, + + // === Serialization === + serialize: serializer.serialize, + deserialize: serializer.deserialize, + getSerializerType: serializer.getSerializerType, + safeSerialize: capture.safeSerialize, + safeDeserialize: capture.safeDeserialize, + + // === Comparison === + comparator: comparator.comparator, + createComparator: comparator.createComparator, + strictComparator: comparator.strictComparator, + looseComparator: comparator.looseComparator, + isClose: comparator.isClose, + + // === Result Comparison (CLI helpers) === + readTestResults: compareResults.readTestResults, + compareResults: compareResults.compareResults, + compareBuffers: compareResults.compareBuffers, + + // === Utilities === + getInvocationIndex: capture.getInvocationIndex, + sanitizeTestId: capture.sanitizeTestId, + + // === Constants === + LOOP_INDEX: capture.LOOP_INDEX, + OUTPUT_FILE: capture.OUTPUT_FILE, + TEST_ITERATION: capture.TEST_ITERATION, + + // === Batch Looping Control (used by loop-runner) === + incrementBatch: capture.incrementBatch, + getCurrentBatch: capture.getCurrentBatch, + checkSharedTimeLimit: capture.checkSharedTimeLimit, + // Getter functions for dynamic env var reading (not constants) + getPerfBatchSize: capture.getPerfBatchSize, + getPerfLoopCount: capture.getPerfLoopCount, + getPerfMinLoops: capture.getPerfMinLoops, + getPerfTargetDurationMs: capture.getPerfTargetDurationMs, + getPerfStabilityCheck: capture.getPerfStabilityCheck, + getPerfCurrentBatch: capture.getPerfCurrentBatch, + + // === Feature Detection === + hasV8: serializer.hasV8, + hasMsgpack: serializer.hasMsgpack, }; diff --git a/packages/codeflash/runtime/react-benchmark/Benchmark.jsx b/packages/codeflash/runtime/react-benchmark/Benchmark.jsx new file mode 100644 index 000000000..eabd42810 --- /dev/null +++ b/packages/codeflash/runtime/react-benchmark/Benchmark.jsx @@ -0,0 +1,220 @@ +const Timing = require("./timing") +const { getMean, getMedian, getStdDev } = require("./math") +const {requireFromRoot} = require("../utils") +const React = requireFromRoot("react") + +const sortNumbers = (a, b) => a - b; + +// eslint-disable-next-line @typescript-eslint/ban-types +function BenchmarkInner( + { + component: Component, + componentProps, + includeLayout = false, + onComplete, + samples: numSamples, + timeout = 10000, + type = 'mount', + }, + ref +) { + const [{ running, cycle, samples, startTime }, dispatch] = React.useReducer(reducer, initialState); + + React.useImperativeHandle(ref, () => ({ + start: () => { + dispatch({ type: 'START', payload: Timing.now() }); + }, + })); + + const shouldRender = getShouldRender(type, cycle); + const shouldRecord = getShouldRecord(type, cycle); + const isDone = getIsDone(type, cycle, numSamples); + + const handleComplete = React.useCallback( + (startTime, endTime, samples) => { + const runTime = endTime - startTime; + const sortedElapsedTimes = samples.map(({ elapsed }) => elapsed).sort(sortNumbers); + const mean = getMean(sortedElapsedTimes); + const stdDev = getStdDev(sortedElapsedTimes); + + const result = { + startTime, + endTime, + runTime, + sampleCount: samples.length, + samples, + max: sortedElapsedTimes[sortedElapsedTimes.length - 1], + min: sortedElapsedTimes[0], + median: getMedian(sortedElapsedTimes), + mean, + stdDev, + p70: mean + stdDev, + p95: mean + stdDev * 2, + p99: mean + stdDev * 3, + layout: undefined, + }; + + if (includeLayout) { + const sortedLayoutTimes = samples.map(({ layout }) => layout).sort(sortNumbers); + const mean = getMean(sortedLayoutTimes); + const stdDev = getStdDev(sortedLayoutTimes); + result.layout = { + max: sortedLayoutTimes[sortedLayoutTimes.length - 1], + min: sortedLayoutTimes[0], + median: getMedian(sortedLayoutTimes), + mean, + stdDev, + p70: mean + stdDev, + p95: mean + stdDev * 2, + p99: mean + stdDev * 3, + }; + } + + onComplete(result); + + dispatch({ type: 'RESET' }); + }, + [includeLayout, onComplete] + ); + + // useMemo causes this to actually run _before_ the component mounts + // as opposed to useEffect, which will run after + React.useMemo(() => { + if (running && shouldRecord) { + dispatch({ type: 'START_SAMPLE', payload: Timing.now() }); + } + }, [cycle, running, shouldRecord]); + + React.useEffect(() => { + if (!running) { + return; + } + + const now = Timing.now(); + + if (shouldRecord && samples.length && samples[samples.length - 1].end < 0) { + if (includeLayout && type !== 'unmount' && document.body) { + document.body.offsetWidth; + } + const layoutEnd = Timing.now(); + + dispatch({ type: 'END_SAMPLE', payload: now }); + dispatch({ type: 'END_LAYOUT', payload: layoutEnd - now }); + return; + } + + const timedOut = now - startTime > timeout; + if (!isDone && !timedOut) { + setTimeout(() => { + dispatch({ type: 'TICK' }); + }, 1); + return; + } else if (isDone || timedOut) { + handleComplete(startTime, now, samples); + } + }, [includeLayout, running, isDone, samples, shouldRecord, shouldRender, startTime, timeout]); + + return running && shouldRender ? ( + // @ts-ignore forcing a testid for cycling + + ) : null; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export const Benchmark = React.forwardRef(BenchmarkInner); + +function reducer(state, action) { + switch (action.type) { + case 'START': + return { + ...state, + startTime: action.payload, + running: true, + }; + + case 'START_SAMPLE': { + const samples = [...state.samples]; + samples.push({ start: action.payload, end: -Infinity, elapsed: -Infinity, layout: -Infinity }); + return { + ...state, + samples, + }; + } + + case 'END_SAMPLE': { + const samples = [...state.samples]; + const index = samples.length - 1; + samples[index].end = action.payload; + samples[index].elapsed = action.payload - samples[index].start; + return { + ...state, + samples, + }; + } + + case 'END_LAYOUT': { + const samples = [...state.samples]; + const index = samples.length - 1; + samples[index].layout = action.payload; + return { + ...state, + samples, + }; + } + + case 'TICK': + return { + ...state, + cycle: state.cycle + 1, + }; + + case 'RESET': + return initialState; + + default: + return state; + } +} + +function getShouldRender(type, cycle) { + switch (type) { + // Render every odd iteration (first, third, etc) + // Mounts and unmounts the component + case 'mount': + case 'unmount': + return !((cycle + 1) % 2); + // Render every iteration (updates previously rendered module) + case 'update': + return true; + default: + return false; + } +} + +function getShouldRecord(type, cycle) { + switch (type) { + // Record every odd iteration (when mounted: first, third, etc) + case 'mount': + return !((cycle + 1) % 2); + // Record every iteration + case 'update': + return cycle !== 0; + // Record every even iteration (when unmounted) + case 'unmount': + return !(cycle % 2); + default: + return false; + } +} + +function getIsDone(type, cycle, numSamples) { + switch (type) { + case 'mount': + case 'unmount': + return cycle >= numSamples * 2 - 1; + case 'update': + return cycle >= numSamples; + default: + return true; + } +} diff --git a/packages/codeflash/runtime/react-benchmark/math.js b/packages/codeflash/runtime/react-benchmark/math.js new file mode 100644 index 000000000..4dd2907d2 --- /dev/null +++ b/packages/codeflash/runtime/react-benchmark/math.js @@ -0,0 +1,24 @@ +export const getStdDev = (values) => { + const avg = getMean(values); + + const squareDiffs = values.map((value) => { + const diff = value - avg; + return diff * diff; + }); + + return Math.sqrt(getMean(squareDiffs)); +}; + +export const getMean = (values) => { + const sum = values.reduce((sum, value) => sum + value, 0); + return sum / values.length; +}; + +export const getMedian = (values) => { + if (values.length === 1) { + return values[0]; + } + + const numbers = values.sort((a, b) => a - b); + return (numbers[(numbers.length - 1) >> 1] + numbers[numbers.length >> 1]) / 2; +}; diff --git a/packages/codeflash/runtime/react-benchmark/run.js b/packages/codeflash/runtime/react-benchmark/run.js new file mode 100644 index 000000000..17b369cc1 --- /dev/null +++ b/packages/codeflash/runtime/react-benchmark/run.js @@ -0,0 +1,38 @@ +const { requireFromRoot } = require("../utils"); +const { Benchmark } = require("./Benchmark") + +const React = requireFromRoot("react") +const { render, act, waitFor } = requireFromRoot("@testing-library/react") + +module.exports = async function runBenchmark({ component, props, samples = 50, type = 'mount' }) { + const ref = React.createRef(); + + let results; + let resolvePromise; + const completionPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const handleComplete = (res) => { + results = res; + resolvePromise(res); + }; + + render( + React.createElement(Benchmark, { + component, + onComplete: handleComplete, + ref, + samples, + componentProps: props, + type, + }) + ); + + act(() => { + ref.current?.start(); + }); + + await completionPromise; + return results; +} diff --git a/packages/codeflash/runtime/react-benchmark/timing.js b/packages/codeflash/runtime/react-benchmark/timing.js new file mode 100644 index 000000000..01674b031 --- /dev/null +++ b/packages/codeflash/runtime/react-benchmark/timing.js @@ -0,0 +1,16 @@ +const NS_PER_MS = 1e6; +const MS_PER_S = 1e3; + +// Returns a high resolution time (if possible) in milliseconds +export function now() { + if (typeof window !== 'undefined' && window.performance) { + return window.performance.now(); + } else if (typeof process !== 'undefined' && process.hrtime) { + const [seconds, nanoseconds] = process.hrtime(); + const secInMS = seconds * MS_PER_S; + const nSecInMS = nanoseconds / NS_PER_MS; + return secInMS + nSecInMS; + } else { + return Date.now(); + } +} \ No newline at end of file diff --git a/packages/codeflash/runtime/utils.js b/packages/codeflash/runtime/utils.js new file mode 100644 index 000000000..3caa02912 --- /dev/null +++ b/packages/codeflash/runtime/utils.js @@ -0,0 +1,21 @@ +const cachedDeps = new Map(); + +function requireFromRoot(moduleName) { + try { + if (cachedDeps.has(moduleName)) return cachedDeps.get(moduleName); + + const modulePath = require.resolve(moduleName, { paths: [process.cwd()] }); + const resolvedModule = require(modulePath); + cachedDeps.set(moduleName, resolvedModule); + return resolvedModule; + } catch (e) { + throw new Error( + `codeflash: Could not resolve '${moduleName}' from project root (${process.cwd()}). ` + + `Ensure ${moduleName} is installed in your project: npm install ${moduleName}` + ); + } +} + +module.exports = { + requireFromRoot, +}; From e0de8d48899b60eeb0f9ba9f77b737c30df77fa9 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 27 Feb 2026 05:16:02 +0530 Subject: [PATCH 45/57] fix: detect foreign config when optimizing external JS/TS projects When running codeflash from the CLI directory against an external project (e.g., Rocket.Chat), the CLI's own pyproject.toml [tool.codeflash] section was overriding path settings like tests_root and module_root. This compares the CLI-provided --module-root against the config's own module-root value to detect and skip foreign configs. Also adds JS/TS detection from file extension when language is not in the config. Co-Authored-By: Claude Opus 4.6 --- codeflash/cli_cmds/cli.py | 42 ++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/codeflash/cli_cmds/cli.py b/codeflash/cli_cmds/cli.py index daee371d7..117adeffe 100644 --- a/codeflash/cli_cmds/cli.py +++ b/codeflash/cli_cmds/cli.py @@ -218,18 +218,51 @@ def process_pyproject_config(args: Namespace) -> Namespace: "git_remote", "override_fixtures", ] + # When --module-root is explicitly provided, check if the config file belongs to a different project. + # This prevents the CLI's own pyproject.toml from overriding path settings (tests_root, module_root, etc.) + # when optimizing files in an external project via `uv run --directory /path/to/cli codeflash ...`. + path_keys = {"module_root", "tests_root", "benchmarks_root"} + config_is_foreign = False + if args.module_root is not None and pyproject_file_path is not None: + try: + module_root_resolved = Path(args.module_root).resolve() + config_dir_resolved = pyproject_file_path.resolve().parent + # Check if the config's own module-root encompasses the CLI-provided module-root. + # If the config has module_root = "codeflash" (relative to config dir), the config's + # module root is config_dir/codeflash. If the CLI's --module-root is NOT under that + # path, the config is foreign even if both are in the same directory tree. + config_module_root = pyproject_config.get("module_root") or pyproject_config.get("module-root") + if config_module_root: + config_module_root_resolved = (config_dir_resolved / config_module_root).resolve() + config_is_foreign = not ( + module_root_resolved == config_module_root_resolved + or config_module_root_resolved in module_root_resolved.parents + ) + else: + config_is_foreign = not ( + module_root_resolved == config_dir_resolved or config_dir_resolved in module_root_resolved.parents + ) + except (ValueError, OSError): + pass + for key in supported_keys: if key in pyproject_config and ( (hasattr(args, key.replace("-", "_")) and getattr(args, key.replace("-", "_")) is None) or not hasattr(args, key.replace("-", "_")) ): + # Skip path-related keys from foreign config files + if config_is_foreign and key in path_keys: + continue setattr(args, key.replace("-", "_"), pyproject_config[key]) assert args.module_root is not None, "--module-root must be specified" assert Path(args.module_root).is_dir(), f"--module-root {args.module_root} must be a valid directory" # For JS/TS projects, tests_root is optional (Jest auto-discovers tests) # Default to module_root if not specified + # Detect JS/TS from config or from the file extension when optimizing an external project is_js_ts_project = pyproject_config.get("language") in ("javascript", "typescript") + if not is_js_ts_project and hasattr(args, "file") and args.file is not None: + is_js_ts_project = Path(args.file).suffix in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts") # Set the test framework singleton for JS/TS projects if is_js_ts_project and pyproject_config.get("test_framework"): @@ -237,13 +270,8 @@ def process_pyproject_config(args: Namespace) -> Namespace: if args.tests_root is None: if is_js_ts_project: - # Try common JS test directories at project root first - for test_dir in ["test", "tests", "__tests__"]: - if Path(test_dir).is_dir(): - args.tests_root = test_dir - break - # If not found at project root, try inside module_root (e.g., src/test, src/__tests__) - if args.tests_root is None and args.module_root: + # Try common JS test directories inside module_root + if args.module_root: module_root_path = Path(args.module_root) for test_dir in ["test", "tests", "__tests__"]: test_path = module_root_path / test_dir From d96c2d8459b44c2fa7d04364f8bba3bdf009b45b Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 27 Feb 2026 05:18:06 +0530 Subject: [PATCH 46/57] fix: enable JS package test directory detection for monorepos Uncomments _find_js_package_test_dir() and fixes the stopping condition so generated test files are placed in the correct package test directory (e.g., apps/meteor/tests/) instead of the CLI directory. Co-Authored-By: Claude Opus 4.6 --- codeflash/verification/verification_utils.py | 23 ++++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/codeflash/verification/verification_utils.py b/codeflash/verification/verification_utils.py index d00e0e3b8..f6939f227 100644 --- a/codeflash/verification/verification_utils.py +++ b/codeflash/verification/verification_utils.py @@ -30,12 +30,12 @@ def get_test_file_path( # For JavaScript/TypeScript, place generated tests in a subdirectory that matches # Vitest/Jest include patterns (e.g., test/**/*.test.ts) - # if is_javascript(): - # # For monorepos, first try to find the package directory from the source file path - # # e.g., packages/workflow/src/utils.ts -> packages/workflow/test/codeflash-generated/ - # package_test_dir = _find_js_package_test_dir(test_dir, source_file_path) - # if package_test_dir: - # test_dir = package_test_dir + if is_javascript(): + # For monorepos, first try to find the package directory from the source file path + # e.g., packages/workflow/src/utils.ts -> packages/workflow/test/codeflash-generated/ + package_test_dir = _find_js_package_test_dir(test_dir, source_file_path) + if package_test_dir: + test_dir = package_test_dir path = test_dir / f"test_{function_name}__{test_type}_test_{iteration}{extension}" if path.exists(): @@ -76,12 +76,7 @@ def _find_js_package_test_dir(tests_root: Path, source_file_path: Path | None) - package_dir = None for parent in source_path.parents: - # Stop if we've gone above or reached the tests_root level - # For monorepos, tests_root might be /packages/ and we want to search within packages - if parent in (tests_root, tests_root.parent): - break - - # Check if this looks like a package root + # Check if this looks like a package root (has package.json or test directory) has_package_json = (parent / "package.json").exists() has_test_dir = any((parent / d).is_dir() for d in ["test", "tests", "__tests__"]) @@ -89,6 +84,10 @@ def _find_js_package_test_dir(tests_root: Path, source_file_path: Path | None) - package_dir = parent break + # Stop if we've gone above the tests_root's parent (beyond the project scope) + if parent == tests_root.parent: + break + if package_dir: # Find the test directory in this package for test_subdir_name in ["test", "tests", "__tests__", "src/__tests__"]: From fd891c9cb3a9e537f0161a01feb39718d1a550f4 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 27 Feb 2026 05:18:32 +0530 Subject: [PATCH 47/57] fix: handle multi-project Jest configs for generated tests Rocket.Chat uses jest.config.ts with a projects: [...] field containing restrictive testMatch patterns that reject generated test files. Detects multi-project configs and creates a flat single-project config so generated tests can run without matching the project's patterns. Co-Authored-By: Claude Opus 4.6 --- codeflash/languages/javascript/test_runner.py | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/codeflash/languages/javascript/test_runner.py b/codeflash/languages/javascript/test_runner.py index 6eadd13ac..a3d73417a 100644 --- a/codeflash/languages/javascript/test_runner.py +++ b/codeflash/languages/javascript/test_runner.py @@ -333,12 +333,84 @@ def _create_codeflash_jest_config( return None +def _is_multi_project_jest_config(config_path: Path) -> bool: + """Check if a Jest config file uses a multi-project setup (projects: [...]). + + Multi-project Jest configs restrict test matching to each project's testMatch patterns, + which prevents codeflash-generated tests from being discovered since they use a + different naming convention (test_*.test.ts vs *.spec.ts). + """ + if config_path is None or not config_path.exists(): + return False + try: + content = config_path.read_text(encoding="utf-8") + # Look for "projects:" or "projects =" in the config — both TS and JS forms + import re + + return bool(re.search(r"\bprojects\s*[=:]", content)) + except Exception: + return False + + +def _create_flat_jest_config_for_generated_tests(project_root: Path, original_config: Path) -> Path | None: + """Create a simple single-project Jest config for running codeflash-generated tests. + + When a project uses a multi-project Jest config (projects: [...]), each project has its own + testMatch/testPathPattern. Generated tests (test_*.test.ts) don't match these patterns. + This function creates a flat config that inherits transform/preset settings but uses + a broad testMatch so generated tests can be discovered. + """ + is_esm = _is_esm_project(project_root) + config_ext = ".cjs" if is_esm else ".js" + codeflash_config_path = original_config.parent / f"jest.codeflash.config{config_ext}" + + # Check if it already exists + if codeflash_config_path.exists(): + return codeflash_config_path + alt_ext = ".js" if is_esm else ".cjs" + alt_path = codeflash_config_path.with_suffix(alt_ext) + if alt_path.exists(): + return alt_path + + has_ts_jest = _has_ts_jest_dependency(project_root) + + if has_ts_jest: + transform_block = """ + transform: { + '^.+\\\\.(ts|tsx)$': ['ts-jest', { isolatedModules: true }], + '^.+\\\\.js$': ['ts-jest', { isolatedModules: true }], + },""" + else: + transform_block = "" + + # Create a flat single-project config (no 'projects' array) with broad testMatch + jest_config_content = f"""// Auto-generated by codeflash — flat config for generated test files +// This replaces the multi-project config so codeflash-generated tests can be discovered +module.exports = {{ + rootDir: '{project_root.as_posix()}', + testEnvironment: 'node', + testMatch: ['**/*.test.ts', '**/*.test.js', '**/*.test.tsx', '**/*.test.jsx', '**/*.spec.ts', '**/*.spec.js'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'],{transform_block} + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], +}}; +""" + try: + codeflash_config_path.write_text(jest_config_content) + _created_config_files.add(codeflash_config_path) + logger.info(f"Created flat Jest config for generated tests: {codeflash_config_path}") + return codeflash_config_path + except Exception as e: + logger.warning(f"Failed to create flat Jest config: {e}") + return None + + def _get_jest_config_for_project(project_root: Path) -> Path | None: """Get the appropriate Jest config for the project. If the project uses bundler moduleResolution, creates and returns a codeflash-compatible Jest config. Otherwise, returns the project's - existing Jest config. + existing Jest config. For multi-project Jest configs, creates a flat + single-project config so codeflash-generated tests can be discovered. Args: project_root: Root of the project. @@ -360,6 +432,14 @@ def _get_jest_config_for_project(project_root: Path) -> Path | None: if codeflash_jest_config: return codeflash_jest_config + # Handle multi-project Jest configs (projects: [...]) — these restrict testMatch + # per-project and prevent generated tests from being discovered + if _is_multi_project_jest_config(original_jest_config): + logger.info("Detected multi-project Jest config — creating flat config for generated tests") + flat_config = _create_flat_jest_config_for_generated_tests(project_root, original_jest_config) + if flat_config: + return flat_config + return original_jest_config From 250d4b1d82949c5180f9824053d15c6c00e66d33 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 27 Feb 2026 05:22:20 +0530 Subject: [PATCH 48/57] fix: add TS/TSX test patterns and co-located test discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .ts/.tsx patterns to _get_test_patterns() (was missing, only had .js/.jsx). Also adds co-located test file discovery — searches source file directories for spec/test files next to source, matching the common JS/TS convention of placing tests alongside source code. Co-Authored-By: Claude Opus 4.6 --- codeflash/languages/javascript/support.py | 51 ++++++++++++++--------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index fcc3c60a9..f55f13102 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -271,7 +271,20 @@ def _get_test_patterns(self) -> list[str]: List of glob patterns for test files. """ - return ["*.test.js", "*.test.jsx", "*.spec.js", "*.spec.jsx", "__tests__/**/*.js", "__tests__/**/*.jsx"] + return [ + "*.test.js", + "*.test.jsx", + "*.spec.js", + "*.spec.jsx", + "*.test.ts", + "*.test.tsx", + "*.spec.ts", + "*.spec.tsx", + "__tests__/**/*.js", + "__tests__/**/*.jsx", + "__tests__/**/*.ts", + "__tests__/**/*.tsx", + ] def discover_tests( self, test_root: Path, source_functions: Sequence[FunctionToOptimize] @@ -280,6 +293,8 @@ def discover_tests( For JavaScript, this uses static analysis to find test files and match them to source functions based on imports and function calls. + Also searches for co-located test files next to source files (a common + JS/TS convention where spec/test files sit alongside source files). Args: test_root: Root directory containing tests. @@ -298,6 +313,21 @@ def discover_tests( for pattern in test_patterns: test_files.extend(test_root.rglob(pattern)) + # Also search for co-located test files next to source files + # This is a common JS/TS convention (e.g., utils.ts + utils.spec.ts in same directory) + seen_paths: set[Path] = {f.resolve() for f in test_files} + source_dirs: set[Path] = set() + for func in source_functions: + if func.file_path and func.file_path.parent not in source_dirs: + source_dirs.add(func.file_path.parent) + for source_dir in source_dirs: + for pattern in test_patterns: + for test_file in source_dir.glob(pattern): + resolved = test_file.resolve() + if resolved not in seen_paths: + test_files.append(test_file) + seen_paths.add(resolved) + for test_file in test_files: try: source = test_file.read_text() @@ -2530,25 +2560,6 @@ def file_extensions(self) -> tuple[str, ...]: """File extensions for TypeScript files.""" return (".ts", ".tsx", ".mts") - def _get_test_patterns(self) -> list[str]: - """Get test file patterns for TypeScript. - - Includes TypeScript patterns plus JavaScript patterns for mixed projects. - - Returns: - List of glob patterns for test files. - - """ - return [ - "*.test.ts", - "*.test.tsx", - "*.spec.ts", - "*.spec.tsx", - "__tests__/**/*.ts", - "__tests__/**/*.tsx", - *super()._get_test_patterns(), - ] - def get_test_file_suffix(self) -> str: """Get the test file suffix for TypeScript. From a46b79beb96b921338515050f8333dbb3d7e3dfd Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 27 Feb 2026 05:22:57 +0530 Subject: [PATCH 49/57] fix: pass git_repo for branch detection and handle staging response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two bugs in PR creation for external projects: 1. get_current_branch() was using CWD (CLI directory) instead of the target project's git repo, causing branch 404 errors on GitHub. 2. cf-api staging fallback returns a JSON object, not a PR number — adds response type detection to handle both cases. Co-Authored-By: Claude Opus 4.6 --- codeflash/api/cfapi.py | 6 ++++-- codeflash/result/create_pr.py | 27 ++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/codeflash/api/cfapi.py b/codeflash/api/cfapi.py index f7957fa0d..862e125b5 100644 --- a/codeflash/api/cfapi.py +++ b/codeflash/api/cfapi.py @@ -285,7 +285,8 @@ def create_staging( optimization_review: str = "", original_line_profiler: str | None = None, optimized_line_profiler: str | None = None, - **_kwargs: Any, # ignores the language argument TODO Hesham: staging for all langs + language: str = "python", + **_kwargs: Any, ) -> Response: """Create a staging pull request, targeting the specified branch. (usually 'staging'). @@ -308,7 +309,7 @@ def create_staging( } payload = { - "baseBranch": get_current_branch(), + "baseBranch": get_current_branch(git.Repo(str(root_dir), search_parent_directories=True)), "diffContents": build_file_changes, "prCommentFields": PrComment( optimization_explanation=explanation.explanation_message(), @@ -321,6 +322,7 @@ def create_staging( winning_behavior_test_results=explanation.winning_behavior_test_results, winning_benchmarking_test_results=explanation.winning_benchmarking_test_results, benchmark_details=explanation.benchmark_details, + language=language, ).to_json(), "existingTests": existing_tests_source, "generatedTests": generated_original_test_source, diff --git a/codeflash/result/create_pr.py b/codeflash/result/create_pr.py index cbde5399a..0f103db9d 100644 --- a/codeflash/result/create_pr.py +++ b/codeflash/result/create_pr.py @@ -287,9 +287,10 @@ def check_create_pr( optimization_review: str = "", original_line_profiler: str | None = None, optimized_line_profiler: str | None = None, + language: str = "python", ) -> None: pr_number: Optional[int] = env_utils.get_pr_number() - git_repo = git.Repo(search_parent_directories=True) + git_repo = git.Repo(str(root_dir), search_parent_directories=True) if pr_number is not None: logger.info(f"Suggesting changes to PR #{pr_number} ...") @@ -323,6 +324,7 @@ def check_create_pr( benchmark_details=explanation.benchmark_details, original_async_throughput=explanation.original_async_throughput, best_async_throughput=explanation.best_async_throughput, + language=language, ), existing_tests=existing_tests_source, generated_tests=generated_original_test_source, @@ -351,7 +353,7 @@ def check_create_pr( logger.warning("⏭️ Branch is not pushed, skipping PR creation...") return relative_path = explanation.file_path.resolve().relative_to(root_dir.resolve()).as_posix() - base_branch = get_current_branch() + base_branch = get_current_branch(git_repo) build_file_changes = { Path(p).resolve().relative_to(root_dir.resolve()).as_posix(): FileDiffContent( oldContent=original_code[p], newContent=new_code[p] @@ -377,6 +379,7 @@ def check_create_pr( benchmark_details=explanation.benchmark_details, original_async_throughput=explanation.original_async_throughput, best_async_throughput=explanation.best_async_throughput, + language=language, ), existing_tests=existing_tests_source, generated_tests=generated_original_test_source, @@ -389,9 +392,23 @@ def check_create_pr( optimized_line_profiler=optimized_line_profiler, ) if response.ok: - pr_id = response.text - pr_url = github_pr_url(owner, repo, pr_id) - logger.info(f"Successfully created a new PR #{pr_id} with the optimized code: {pr_url}") + # The cf-api returns a PR number on success, or a JSON object when staging is used as fallback + try: + response_data = response.json() + if isinstance(response_data, int): + pr_url = github_pr_url(owner, repo, str(response_data)) + logger.info(f"Successfully created a new PR #{response_data} with the optimized code: {pr_url}") + elif isinstance(response_data, dict) and "storageType" in response_data: + logger.info( + f"PR creation fell back to staging (storageType: {response_data.get('storageType')}). " + f"The optimization is saved and can be reviewed in the Codeflash dashboard." + ) + else: + logger.info(f"PR creation response: {response.text}") + except Exception: + pr_id = response.text + pr_url = github_pr_url(owner, repo, pr_id) + logger.info(f"Successfully created a new PR #{pr_id} with the optimized code: {pr_url}") else: logger.error( f"Optimization was successful, but I failed to create a PR with the optimized code." From 6ee3458a427015b56711443a4b659f9814d3ff40 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 27 Feb 2026 05:23:40 +0530 Subject: [PATCH 50/57] feat: language-aware PR descriptions for JS/TS - TestType.to_name() now accepts language parameter; JS/TS shows only relevant test types (Existing Tests, Generated Tests) instead of Python-specific ones (Replay Tests, Concolic Coverage Tests) - PrComment carries language field through to cf-api for code block syntax and test report formatting - Console syntax highlighting uses typescript for JS/TS instead of hardcoded python Co-Authored-By: Claude Opus 4.6 --- codeflash/github/PrComment.py | 4 +++- codeflash/models/test_type.py | 9 ++++++++- codeflash/optimization/function_optimizer.py | 9 +++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/codeflash/github/PrComment.py b/codeflash/github/PrComment.py index 85c24ec57..965859329 100644 --- a/codeflash/github/PrComment.py +++ b/codeflash/github/PrComment.py @@ -23,11 +23,12 @@ class PrComment: benchmark_details: Optional[list[BenchmarkDetail]] = None original_async_throughput: Optional[int] = None best_async_throughput: Optional[int] = None + language: str = "python" def to_json(self) -> dict[str, Union[str, int, dict[str, dict[str, int]], list[BenchmarkDetail], None]]: report_table: dict[str, dict[str, int]] = {} for test_type, counts in self.winning_behavior_test_results.get_test_pass_fail_report_by_type().items(): - name = test_type.to_name() + name = test_type.to_name(self.language) if name: report_table[name] = counts @@ -42,6 +43,7 @@ def to_json(self) -> dict[str, Union[str, int, dict[str, dict[str, int]], list[B "loop_count": self.winning_benchmarking_test_results.number_of_loops(), "report_table": report_table, "benchmark_details": self.benchmark_details if self.benchmark_details else None, + "language": self.language, } if self.original_async_throughput is not None and self.best_async_throughput is not None: diff --git a/codeflash/models/test_type.py b/codeflash/models/test_type.py index 154e3f7f2..55dc440aa 100644 --- a/codeflash/models/test_type.py +++ b/codeflash/models/test_type.py @@ -9,7 +9,9 @@ class TestType(Enum): CONCOLIC_COVERAGE_TEST = 5 INIT_STATE_TEST = 6 - def to_name(self) -> str: + def to_name(self, language: str = "python") -> str: + if language in ("javascript", "typescript"): + return _JS_TS_NAME_MAP.get(self, "") return _TO_NAME_MAP.get(self, "") @@ -20,3 +22,8 @@ def to_name(self) -> str: TestType.REPLAY_TEST: "⏪ Replay Tests", TestType.CONCOLIC_COVERAGE_TEST: "🔎 Concolic Coverage Tests", } + +_JS_TS_NAME_MAP: dict[TestType, str] = { + TestType.EXISTING_UNIT_TEST: "⚙️ Existing Tests", + TestType.GENERATED_REGRESSION: "🌀 Generated Tests", +} diff --git a/codeflash/optimization/function_optimizer.py b/codeflash/optimization/function_optimizer.py index d9825c1fc..1b7586dfa 100644 --- a/codeflash/optimization/function_optimizer.py +++ b/codeflash/optimization/function_optimizer.py @@ -1394,10 +1394,13 @@ def log_successful_optimization( ) if self.args.no_pr: + syntax_lang = ( + "typescript" if self.function_to_optimize.language in ("javascript", "typescript") else "python" + ) tests_panel = Panel( Syntax( "\n".join([test.generated_original_test_source for test in generated_tests.generated_tests]), - "python", + syntax_lang, line_numbers=True, ), title="Validated Tests", @@ -2227,9 +2230,7 @@ def process_review( if "root_dir" not in data: data["root_dir"] = git_root_dir(GitRepo(str(self.args.module_root), search_parent_directories=True)) data["git_remote"] = self.args.git_remote - # Remove language from data dict as check_create_pr doesn't accept it - pr_data = {k: v for k, v in data.items() if k != "language"} - check_create_pr(**pr_data) + check_create_pr(**data) elif staging_review: response = create_staging(**data) if response.status_code == 200: From cbf2f5b68780822a6661e706f37838da8856fbf3 Mon Sep 17 00:00:00 2001 From: "codeflash-ai[bot]" <148906541+codeflash-ai[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:42:57 +0000 Subject: [PATCH 51/57] Optimize RenderCallTransformer.transform Brief: The optimized transform is dramatically faster in wall-clock runtime (1.86 s -> 7.31 ms, ~25,360% faster) by eliminating repeated linear rescans and duplicate regex searches inside the main loop. The hot-path work that used to be repeated for each match is now done once up-front or done with a single faster lookup per match. What changed (concrete optimizations) - Single in-string pass: Introduced _compute_in_string_map which does one linear pass over the source and produces a bytearray where map[pos] indicates whether the parser would be inside a string at that position. This mirrors is_inside_string but computes the whole map in O(n) once instead of repeatedly scanning from the start. - One regex search per loop: Replaced two separate .search() calls and the per-iteration comparison logic with a single combined_pattern using an alternation (React.createElement | _jsx/_jsxs). The loop performs one regex.search per iteration instead of two. - Micro-optimizations: match.start()/match.end() are cached to locals (start/end) and the in-string check becomes a constant-time map lookup (in_string_map[start]) instead of calling the helper. Why this is faster (mechanics) - Removed O(n) repeated work: The original code called is_inside_string(match.start()) for each match, which scanned the source up to match.start() every time. In large inputs with many matches that becomes effectively O(m * n) scanning. The in-string bytearray makes that check O(1), turning the repeated scanning into a single O(n) pass. - Halved regex work: Combining the two patterns into a single alternation halves the number of regex searches per loop iteration and reduces regex engine overhead and matching effort. - Lower per-match overhead: Using local variables (start/end) and avoiding duplicate attribute lookups reduces Python-level overhead in the hot loop. Trade-offs and small-regression note - For tiny inputs, you may see a small increase in per-test time in the annotated unit tests because of the upfront cost of computing the in-string map. The annotated tests show microbench increases on very small examples (single render calls). This is an expected and acceptable trade-off because the big win is for large inputs or many matches (the real hot path). The large-scale test demonstrates the optimization's purpose: transforming 1000 calls went from ~1.86 s to ~7 ms. Behavior and safety - The new _compute_in_string_map mirrors the old is_inside_string logic (including escaped characters) so behavior is preserved. The combined regex preserves the same matching cases via alternation, so transformations remain correct. Practical impact (when this matters) - Big benefit when transform() is used on long source strings with many render(...) occurrences (the "large_scale_many_render_calls_transformed" test demonstrates this). - Minimal impact for one-off small files; an upfront O(n) map construction is cheap relative to the repeated rescans when there are many matches. Next obvious improvement point - The line profiler still shows parse/generation (self._parse_render_call and _generate_transformed_call) contribute measurable time per match in the optimized profile. If further speedup is needed, target reducing overhead in _parse_render_call or make its work incremental. Summary - Primary runtime win: replaced repeated linear rescans with one linear pass and replaced two regex searches per iteration with one. This converted a pathological repeated-scan hot path into an O(n) precomputation + cheap O(1) checks per match, producing the large runtime improvement while keeping functional behavior intact. --- codeflash/languages/javascript/instrument.py | 87 +++++++++++++------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/codeflash/languages/javascript/instrument.py b/codeflash/languages/javascript/instrument.py index 96ed550c6..32e7de07d 100644 --- a/codeflash/languages/javascript/instrument.py +++ b/codeflash/languages/javascript/instrument.py @@ -949,58 +949,53 @@ def __init__(self, function_to_optimize: FunctionToOptimize, capture_func: str) # render(_jsx(ComponentName, props)) or render(_jsxs(ComponentName, props)) self._render_jsx_pattern = re.compile(rf"(\s*)render\s*\(\s*_jsxs?\s*\(\s*{re.escape(self.func_name)}\b") + # Combined pattern to find either occurrence in a single search + self._combined_pattern = re.compile( + rf"(\s*)render\s*\(\s*(?:React\.createElement\s*\(\s*{re.escape(self.func_name)}\b|_jsxs?\s*\(\s*{re.escape(self.func_name)}\b)" + ) + def transform(self, code: str) -> str: """Transform all render(React.createElement(Component, ...)) calls in the code.""" result: list[str] = [] pos = 0 - while pos < len(code): - # Try both React.createElement and _jsx/_jsxs patterns - ce_match = self._render_create_element_pattern.search(code, pos) - jsx_match = self._render_jsx_pattern.search(code, pos) + # Precompute in-string map once to avoid repeated scans + in_string_map = self._compute_in_string_map(code) - # Choose the first match (by position) - match = None - is_jsx = False - if ce_match and jsx_match: - if ce_match.start() <= jsx_match.start(): - match = ce_match - else: - match = jsx_match - is_jsx = True - elif ce_match: - match = ce_match - elif jsx_match: - match = jsx_match - is_jsx = True + while pos < len(code): + # Use combined pattern to find the next relevant render(...) occurrence + match = self._combined_pattern.search(code, pos) if not match: result.append(code[pos:]) break + start = match.start() + end = match.end() + # Skip if inside a string literal - if is_inside_string(code, match.start()): - result.append(code[pos : match.end()]) - pos = match.end() + if in_string_map[start]: + result.append(code[pos:end]) + pos = end continue # Skip if already transformed with codeflash.captureRender - lookback_start = max(0, match.start() - 60) - lookback = code[lookback_start : match.start()] + lookback_start = max(0, start - 60) + lookback = code[lookback_start:start] if f"codeflash.{self.capture_func}(" in lookback: - result.append(code[pos : match.end()]) - pos = match.end() + result.append(code[pos:end]) + pos = end continue # Add everything before the match - result.append(code[pos : match.start()]) + result.append(code[pos:start]) # Try to parse the full render call render_match = self._parse_render_call(code, match) if render_match is None: # Couldn't parse, skip this match - result.append(code[match.start() : match.end()]) - pos = match.end() + result.append(code[start:end]) + pos = end continue # Generate the transformed code @@ -1107,6 +1102,42 @@ def _generate_transformed_call(self, match: RenderCallMatch) -> str: f"'{line_id}', render, {self.func_name}){semicolon}" ) + def _compute_in_string_map(self, code: str) -> bytearray: + """Compute a bytearray of length len(code)+1 where map[pos] is 1 if + the parser would be inside a string after processing code[:pos], else 0. + + This mirrors the logic of is_inside_string but does a single linear pass. + """ + n = len(code) + arr = bytearray(n + 1) + in_string = False + string_char = None + i = 0 + # arr[0] is already 0 + while i < n: + c = code[i] + if in_string: + # Check for escape sequence + if c == "\\" and i + 1 < n: + # After processing the backslash and escaped char, still inside string + arr[i + 1] = 1 + if i + 2 <= n: + arr[i + 2] = 1 + i += 2 + continue + # Check for end of string + if c == string_char: + in_string = False + string_char = None + else: + # Check for start of string + if c in "\"'`": + in_string = True + string_char = c + arr[i + 1] = 1 if in_string else 0 + i += 1 + return arr + def transform_render_calls( code: str, function_to_optimize: FunctionToOptimize, capture_func: str, start_counter: int = 0 From 8af07af89eb0827ba250279f6072ca8c6b7dd5dc Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:50:30 +0000 Subject: [PATCH 52/57] style: fix linting and type annotation issues Fix D205 docstring formatting, PLR5501 collapsible-else-if, and add missing generic type parameters for mypy strict mode. Co-Authored-By: Claude Opus 4.6 --- codeflash/languages/base.py | 6 +++--- codeflash/languages/javascript/instrument.py | 17 ++++++++--------- codeflash/languages/javascript/parse.py | 2 +- codeflash/languages/javascript/test_runner.py | 6 +++--- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/codeflash/languages/base.py b/codeflash/languages/base.py index ce19c536b..ae439bf6c 100644 --- a/codeflash/languages/base.py +++ b/codeflash/languages/base.py @@ -624,7 +624,7 @@ def extract_calling_function_source(self, source_code: str, function_name: str, def compare_test_results( self, original_results_path: Path, candidate_results_path: Path, project_root: Path | None = None - ) -> tuple[bool, list]: + ) -> tuple[bool, list[Any]]: """Compare test results between original and candidate code. Args: @@ -727,7 +727,7 @@ def instrument_source_for_line_profiler( """Instrument source code before line profiling.""" ... - def parse_line_profile_results(self, line_profiler_output_file: Path) -> dict: + def parse_line_profile_results(self, line_profiler_output_file: Path) -> dict[str, Any]: """Parse line profiler output.""" ... @@ -790,7 +790,7 @@ def run_benchmarking_tests( ... -def convert_parents_to_tuple(parents: list | tuple) -> tuple[FunctionParent, ...]: +def convert_parents_to_tuple(parents: list[Any] | tuple[Any, ...]) -> tuple[FunctionParent, ...]: """Convert a list of parent objects to a tuple of FunctionParent. Args: diff --git a/codeflash/languages/javascript/instrument.py b/codeflash/languages/javascript/instrument.py index 32e7de07d..d5437f5d8 100644 --- a/codeflash/languages/javascript/instrument.py +++ b/codeflash/languages/javascript/instrument.py @@ -319,7 +319,7 @@ def transform(self, code: str) -> str: return "".join(result) - def _should_skip_match(self, code: str, start: int, match: re.Match) -> bool: + def _should_skip_match(self, code: str, start: int, match: re.Match[str]) -> bool: """Check if the match should be skipped (inside expect, already transformed, etc.).""" # Skip if inside a string literal (e.g., test description) if is_inside_string(code, start): @@ -403,7 +403,7 @@ def _find_matching_paren(self, code: str, open_paren_pos: int) -> int: return pos if depth == 0 else -1 - def _parse_standalone_call(self, code: str, match: re.Match) -> StandaloneCallMatch | None: + def _parse_standalone_call(self, code: str, match: re.Match[str]) -> StandaloneCallMatch | None: """Parse a complete standalone func(...) call.""" leading_ws = match.group(1) prefix = match.group(2) or "" # "await " or "" @@ -1103,9 +1103,9 @@ def _generate_transformed_call(self, match: RenderCallMatch) -> str: ) def _compute_in_string_map(self, code: str) -> bytearray: - """Compute a bytearray of length len(code)+1 where map[pos] is 1 if - the parser would be inside a string after processing code[:pos], else 0. + """Compute a bytearray of length len(code)+1 where map[pos] is 1 if pos is inside a string. + map[pos] is 1 if the parser would be inside a string after processing code[:pos], else 0. This mirrors the logic of is_inside_string but does a single linear pass. """ n = len(code) @@ -1129,11 +1129,10 @@ def _compute_in_string_map(self, code: str) -> bytearray: if c == string_char: in_string = False string_char = None - else: - # Check for start of string - if c in "\"'`": - in_string = True - string_char = c + # Check for start of string + elif c in "\"'`": + in_string = True + string_char = c arr[i + 1] = 1 if in_string else 0 i += 1 return arr diff --git a/codeflash/languages/javascript/parse.py b/codeflash/languages/javascript/parse.py index e7e8a2ad2..2178b3cdd 100644 --- a/codeflash/languages/javascript/parse.py +++ b/codeflash/languages/javascript/parse.py @@ -517,7 +517,7 @@ def parse_jest_test_xml( # Find matching end marker end_key = groups[:5] - end_match = end_matches_dict.get(end_key) + end_match: re.Match[str] | None = end_matches_dict.get(end_key) runtime = None if end_match: diff --git a/codeflash/languages/javascript/test_runner.py b/codeflash/languages/javascript/test_runner.py index a3d73417a..7d7df08d4 100644 --- a/codeflash/languages/javascript/test_runner.py +++ b/codeflash/languages/javascript/test_runner.py @@ -770,7 +770,7 @@ def run_jest_behavioral_tests( project_root: Path | None = None, enable_coverage: bool = False, candidate_index: int = 0, -) -> tuple[Path, subprocess.CompletedProcess, Path | None, Path | None]: +) -> tuple[Path, subprocess.CompletedProcess[str], Path | None, Path | None]: """Run Jest tests and return results in a format compatible with pytest output. Args: @@ -1017,7 +1017,7 @@ def run_jest_benchmarking_tests( max_loops: int = 100, target_duration_ms: int = 10_000, # 10 seconds for benchmarking tests stability_check: bool = True, -) -> tuple[Path, subprocess.CompletedProcess]: +) -> tuple[Path, subprocess.CompletedProcess[str]]: """Run Jest benchmarking tests with in-process session-level looping. Uses a custom Jest runner (codeflash/loop-runner) to loop all tests @@ -1195,7 +1195,7 @@ def run_jest_line_profile_tests( timeout: int | None = None, project_root: Path | None = None, line_profile_output_file: Path | None = None, -) -> tuple[Path, subprocess.CompletedProcess]: +) -> tuple[Path, subprocess.CompletedProcess[str]]: """Run Jest tests for line profiling. This runs tests against source code that has been instrumented with line profiler. From 5c1aed3b6d05016da11faa42796da3ddd6f7627d Mon Sep 17 00:00:00 2001 From: ali Date: Wed, 4 Mar 2026 21:09:10 +0200 Subject: [PATCH 53/57] minor changes --- .../src/components/ServerDashboard.tsx | 10 +- codeflash/languages/javascript/support.py | 11 +- codeflash/languages/javascript/test_runner.py | 8 +- codeflash/verification/test_runner.py | 12 + codeflash/version.py | 2 +- tests/react/fixtures/.gitignore | 8 + tests/react/fixtures/Counter.tsx | 5 +- tests/react/fixtures/package-lock.json | 1412 ++++++++++++++++- tests/react/fixtures/package.json | 5 + tests/react/fixtures/playwright-ct.config.ts | 46 + tests/react/fixtures/playwright/index.html | 12 + tests/react/fixtures/playwright/index.tsx | 2 + tests/react/fixtures/tests/Counter.spec.tsx | 47 + 13 files changed, 1535 insertions(+), 45 deletions(-) create mode 100644 tests/react/fixtures/.gitignore create mode 100644 tests/react/fixtures/playwright-ct.config.ts create mode 100644 tests/react/fixtures/playwright/index.html create mode 100644 tests/react/fixtures/playwright/index.tsx create mode 100644 tests/react/fixtures/tests/Counter.spec.tsx diff --git a/code_to_optimize/js/code_to_optimize_react/src/components/ServerDashboard.tsx b/code_to_optimize/js/code_to_optimize_react/src/components/ServerDashboard.tsx index 604b9d725..2edc3f046 100644 --- a/code_to_optimize/js/code_to_optimize_react/src/components/ServerDashboard.tsx +++ b/code_to_optimize/js/code_to_optimize_react/src/components/ServerDashboard.tsx @@ -18,13 +18,17 @@ export async function ServerDashboard({ orgId }: { orgId: string }) { const response = await fetch(`/api/dashboard/${orgId}`); const data: DashboardData = await response.json(); + // Extract derived values once to avoid repeated property access / computations + const { totalUsers, activeUsers, revenue } = data; + const revenueText = "$" + revenue.toFixed(2); + return (

    Dashboard

    -

    Total Users: {data.totalUsers}

    -

    Active Users: {data.activeUsers}

    -

    Revenue: ${data.revenue.toFixed(2)}

    +

    Total Users: {totalUsers}

    +

    Active Users: {activeUsers}

    +

    Revenue: {revenueText}

    ); diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index fcc3c60a9..3b5b88bcf 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -2388,10 +2388,6 @@ def run_behavioral_tests( candidate_index=candidate_index, ) - # JavaScript/TypeScript benchmarking uses high max_loops like Python (100,000) - # The actual loop count is limited by target_duration_seconds, not max_loops - JS_BENCHMARKING_MAX_LOOPS = 100_000 - def run_benchmarking_tests( self, test_paths: Any, @@ -2426,9 +2422,6 @@ def run_benchmarking_tests( framework = test_framework or get_js_test_framework_or_default() logger.debug("run_benchmarking_tests called with framework=%s", framework) - # Use JS-specific high max_loops - actual loop count is limited by target_duration - effective_max_loops = self.JS_BENCHMARKING_MAX_LOOPS - if framework == "vitest": from codeflash.languages.javascript.vitest_runner import run_vitest_benchmarking_tests @@ -2440,7 +2433,7 @@ def run_benchmarking_tests( timeout=timeout, project_root=project_root, min_loops=min_loops, - max_loops=effective_max_loops, + max_loops=max_loops, target_duration_ms=int(target_duration_seconds * 1000), ) @@ -2453,7 +2446,7 @@ def run_benchmarking_tests( timeout=timeout, project_root=project_root, min_loops=min_loops, - max_loops=effective_max_loops, + max_loops=max_loops, target_duration_ms=int(target_duration_seconds * 1000), ) diff --git a/codeflash/languages/javascript/test_runner.py b/codeflash/languages/javascript/test_runner.py index 6eadd13ac..b2748be9d 100644 --- a/codeflash/languages/javascript/test_runner.py +++ b/codeflash/languages/javascript/test_runner.py @@ -842,7 +842,6 @@ def run_jest_behavioral_tests( wall_clock_ns = time.perf_counter_ns() - start_time_ns logger.debug(f"Jest behavioral tests completed in {wall_clock_ns / 1e9:.2f}s") - print(result.stdout) return result_file_path, result, coverage_json_path, None @@ -991,6 +990,7 @@ def run_jest_benchmarking_tests( "--runInBand", # Ensure serial execution "--forceExit", "--runner=codeflash/loop-runner", # Use custom loop runner for in-process looping + # "--no-cache" ] # Add Jest config if found - needed for TypeScript transformation @@ -1091,9 +1091,9 @@ def run_jest_benchmarking_tests( # Create result with combined stdout result = subprocess.CompletedProcess(args=result.args, returncode=result.returncode, stdout=stdout, stderr="") if result.returncode != 0: - logger.info(f"Jest benchmarking failed with return code {result.returncode}") - logger.info(f"Jest benchmarking stdout: {result.stdout}") - logger.info(f"Jest benchmarking stderr: {result.stderr}") + logger.debug(f"Jest benchmarking failed with return code {result.returncode}") + logger.debug(f"Jest benchmarking stdout: {result.stdout}") + logger.debug(f"Jest benchmarking stderr: {result.stderr}") except subprocess.TimeoutExpired: logger.warning(f"Jest benchmarking timed out after {total_timeout}s") diff --git a/codeflash/verification/test_runner.py b/codeflash/verification/test_runner.py index a64bdd8e1..d4c64879c 100644 --- a/codeflash/verification/test_runner.py +++ b/codeflash/verification/test_runner.py @@ -329,6 +329,18 @@ def run_benchmarking_tests( # Check if there's a language support for this test framework that implements run_benchmarking_tests language_support = get_language_support_by_framework(test_framework) if language_support is not None and hasattr(language_support, "run_benchmarking_tests"): + # needs_warmup = test_framework == "jest" + # if needs_warmup: + # language_support.run_benchmarking_tests( + # test_paths=test_paths, + # test_env=test_env, + # cwd=cwd, + # timeout=pytest_timeout, + # project_root=js_project_root, + # min_loops=1, + # max_loops=1, + # target_duration_seconds=pytest_target_runtime_seconds, + # ) return language_support.run_benchmarking_tests( test_paths=test_paths, test_env=test_env, diff --git a/codeflash/version.py b/codeflash/version.py index 57668e5ec..5c0c09b55 100644 --- a/codeflash/version.py +++ b/codeflash/version.py @@ -1,2 +1,2 @@ # These version placeholders will be replaced by uv-dynamic-versioning during build. -__version__ = "0.20.1.post141.dev0+80380063" +__version__ = "0.20.1" diff --git a/tests/react/fixtures/.gitignore b/tests/react/fixtures/.gitignore new file mode 100644 index 000000000..335bd46df --- /dev/null +++ b/tests/react/fixtures/.gitignore @@ -0,0 +1,8 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/tests/react/fixtures/Counter.tsx b/tests/react/fixtures/Counter.tsx index 0d9b5941d..ec406e25b 100644 --- a/tests/react/fixtures/Counter.tsx +++ b/tests/react/fixtures/Counter.tsx @@ -10,7 +10,10 @@ export function Counter({ initialCount = 0, label = 'Count' }: CounterProps) { const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); - + if (!window.__CODEFLASH_RENDERS__){ + window.__CODEFLASH_RENDERS__ = [] + } + window.__CODEFLASH_RENDERS__.push("hey") return (
    {label}: {count} diff --git a/tests/react/fixtures/package-lock.json b/tests/react/fixtures/package-lock.json index 3c6f97759..e70ff79bc 100644 --- a/tests/react/fixtures/package-lock.json +++ b/tests/react/fixtures/package-lock.json @@ -16,9 +16,11 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@playwright/experimental-ct-react": "^1.58.2", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.0.0", + "@types/node": "^25.3.3", "@types/react": "^18.0.0", "jest": "^29.0.0", "typescript": "^5.0.0" @@ -510,6 +512,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -1743,35 +1777,1014 @@ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/experimental-ct-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.58.2.tgz", + "integrity": "sha512-Imif9ggQp6YIblHAX6MvJuqDFrCGHYspoibxLP3+1soXp+1wBNuuSRajv0VWXzFbh//4l29I+xy2tpTgej0vEA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2", + "playwright-core": "1.58.2", + "vite": "^6.4.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@playwright/experimental-ct-react": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-react/-/experimental-ct-react-1.58.2.tgz", + "integrity": "sha512-8IdT7b+c3Wv8RVRKmAkAL2PWJU85Tu0IfqsvsbaS520OOoZJqytIoxdU7gog9gjaONpgw6SOKDykEWup3SeO7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@playwright/experimental-ct-core": "1.58.2", + "@vitejs/plugin-react": "^4.2.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=6.0.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@sinclair/typebox": { "version": "0.27.10", @@ -1958,6 +2971,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -2003,9 +3023,9 @@ } }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -2066,6 +3086,27 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -6149,6 +7190,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -6561,6 +7621,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -6580,6 +7687,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -6690,6 +7826,16 @@ "dev": true, "license": "MIT" }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -6841,6 +7987,51 @@ "node": ">=0.12" } }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -7552,6 +8743,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-resolve": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", @@ -7807,6 +9008,54 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -8105,6 +9354,115 @@ "node": ">=10.12.0" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/tests/react/fixtures/package.json b/tests/react/fixtures/package.json index 007c31693..350363f27 100644 --- a/tests/react/fixtures/package.json +++ b/tests/react/fixtures/package.json @@ -10,9 +10,11 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@playwright/experimental-ct-react": "^1.58.2", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.0.0", + "@types/node": "^25.3.3", "@types/react": "^18.0.0", "jest": "^29.0.0", "typescript": "^5.0.0" @@ -23,5 +25,8 @@ "ignorePaths": [ "node_modules" ] + }, + "scripts": { + "test-ct": "playwright test -c playwright-ct.config.ts" } } diff --git a/tests/react/fixtures/playwright-ct.config.ts b/tests/react/fixtures/playwright-ct.config.ts new file mode 100644 index 000000000..a2c94b747 --- /dev/null +++ b/tests/react/fixtures/playwright-ct.config.ts @@ -0,0 +1,46 @@ +import { defineConfig, devices } from '@playwright/experimental-ct-react'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './', + /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */ + snapshotDir: './__snapshots__', + /* Maximum time one test can run for. */ + timeout: 10 * 1000, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + /* Port to use for Playwright component endpoint. */ + ctPort: 3100, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}); diff --git a/tests/react/fixtures/playwright/index.html b/tests/react/fixtures/playwright/index.html new file mode 100644 index 000000000..610ddf8a4 --- /dev/null +++ b/tests/react/fixtures/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Testing Page + + +
    + + + diff --git a/tests/react/fixtures/playwright/index.tsx b/tests/react/fixtures/playwright/index.tsx new file mode 100644 index 000000000..ac6de14bf --- /dev/null +++ b/tests/react/fixtures/playwright/index.tsx @@ -0,0 +1,2 @@ +// Import styles, initialize component theme here. +// import '../src/common.css'; diff --git a/tests/react/fixtures/tests/Counter.spec.tsx b/tests/react/fixtures/tests/Counter.spec.tsx new file mode 100644 index 000000000..f1812cbbb --- /dev/null +++ b/tests/react/fixtures/tests/Counter.spec.tsx @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import {Counter} from '../Counter'; +import React from 'react'; + +test.describe('Counter component', () => { + test('renders with default values', async ({ mount, page }) => { + const component = await mount(); + + await expect(component).toContainText('Count: 0'); + + const renders = await page.evaluate(() => window.__CODEFLASH_RENDERS__); + console.log({renders}) + + }); + + test('renders with custom initialCount and label', async ({ mount }) => { + const component = await mount( + + ); + + await expect(component).toContainText('Clicks: 5'); + }); + + test('increments count when + is clicked', async ({ mount }) => { + const component = await mount(); + + await component.getByText('+').click(); + await expect(component).toContainText('Count: 2'); + }); + + test('decrements count when - is clicked', async ({ mount }) => { + const component = await mount(); + + await component.getByText('-').click(); + await expect(component).toContainText('Count: 2'); + }); + + test('multiple increments and decrements work correctly', async ({ mount }) => { + const component = await mount(); + + await component.getByText('+').click(); + await component.getByText('+').click(); + await component.getByText('-').click(); + + await expect(component).toContainText('Count: 1'); + }); +}); From 4d9272806650abd632e31c6ed11133b13af85041 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Tue, 10 Mar 2026 22:36:35 +0530 Subject: [PATCH 54/57] render count vs mount count instrumentation --- codeflash/github/PrComment.py | 4 + codeflash/models/models.py | 3 + codeflash/optimization/function_optimizer.py | 101 +++++++++++++++++++ codeflash/result/create_pr.py | 2 + codeflash/result/critic.py | 49 ++++++++- packages/codeflash/runtime/capture.js | 14 +++ 6 files changed, 169 insertions(+), 4 deletions(-) diff --git a/codeflash/github/PrComment.py b/codeflash/github/PrComment.py index 965859329..b50fc7b61 100644 --- a/codeflash/github/PrComment.py +++ b/codeflash/github/PrComment.py @@ -24,6 +24,7 @@ class PrComment: original_async_throughput: Optional[int] = None best_async_throughput: Optional[int] = None language: str = "python" + render_benchmark_markdown: Optional[str] = None def to_json(self) -> dict[str, Union[str, int, dict[str, dict[str, int]], list[BenchmarkDetail], None]]: report_table: dict[str, dict[str, int]] = {} @@ -50,6 +51,9 @@ def to_json(self) -> dict[str, Union[str, int, dict[str, dict[str, int]], list[B result["original_async_throughput"] = self.original_async_throughput result["best_async_throughput"] = self.best_async_throughput + if self.render_benchmark_markdown: + result["render_benchmark_markdown"] = self.render_benchmark_markdown + return result diff --git a/codeflash/models/models.py b/codeflash/models/models.py index 697601403..dfea5ddf4 100644 --- a/codeflash/models/models.py +++ b/codeflash/models/models.py @@ -174,6 +174,7 @@ class BestOptimization(BaseModel): line_profiler_test_results: dict[Any, Any] async_throughput: Optional[int] = None concurrency_metrics: Optional[ConcurrencyMetrics] = None + render_benchmark: Optional[Any] = None @dataclass(frozen=True) @@ -400,6 +401,7 @@ class OptimizedCandidateResult(BaseModel): total_candidate_timing: int async_throughput: Optional[int] = None concurrency_metrics: Optional[ConcurrencyMetrics] = None + render_profiles: Optional[list[Any]] = None class GeneratedTests(BaseModel): @@ -633,6 +635,7 @@ class OriginalCodeBaseline(BaseModel): coverage_results: Optional[CoverageData] async_throughput: Optional[int] = None concurrency_metrics: Optional[ConcurrencyMetrics] = None + render_profiles: Optional[list[Any]] = None class CoverageStatus(Enum): diff --git a/codeflash/optimization/function_optimizer.py b/codeflash/optimization/function_optimizer.py index 1b7586dfa..150f11b11 100644 --- a/codeflash/optimization/function_optimizer.py +++ b/codeflash/optimization/function_optimizer.py @@ -496,6 +496,56 @@ def __init__( self.is_numerical_code: bool | None = None self.code_already_exists: bool = False + @property + def is_react_component(self) -> bool: + metadata = self.function_to_optimize.metadata or {} + return bool(metadata.get("is_react_component", False)) + + def instrument_source_with_react_profiler(self) -> str | None: + """Instrument the source file with React.Profiler if this is a React component. + + Returns the original source code (for restoration) if instrumentation succeeded, None otherwise. + """ + if not self.is_react_component: + return None + try: + from codeflash.languages.javascript.frameworks.react.profiler import instrument_component_with_profiler + from codeflash.languages.javascript.treesitter import get_analyzer_for_file + + file_path = self.function_to_optimize.file_path + original_source = file_path.read_text("utf-8") + analyzer = get_analyzer_for_file(file_path) + instrumented = instrument_component_with_profiler( + original_source, self.function_to_optimize.function_name, analyzer + ) + if instrumented != original_source: + file_path.write_text(instrumented, encoding="utf-8") + logger.debug(f"Instrumented {self.function_to_optimize.function_name} with React.Profiler") + return original_source + except Exception: + logger.debug("Failed to instrument source with React.Profiler", exc_info=True) + return None + + def restore_source_after_profiler(self, original_source: str | None) -> None: + """Restore the source file after React.Profiler instrumentation.""" + if original_source is not None: + self.function_to_optimize.file_path.write_text(original_source, encoding="utf-8") + + def parse_render_profiles_from_results(self, test_results: TestResults) -> list | None: + """Parse React Profiler render markers from test stdout.""" + if not self.is_react_component or not test_results.perf_stdout: + return None + try: + from codeflash.languages.javascript.parse import parse_react_render_markers + + profiles = parse_react_render_markers(test_results.perf_stdout) + if profiles: + logger.debug(f"Parsed {len(profiles)} React render profiles from test output") + return profiles + except Exception: + logger.debug("Failed to parse React render markers", exc_info=True) + return None + def can_be_optimized(self) -> Result[tuple[bool, CodeOptimizationContext, dict[Path, str]], str]: should_run_experiment = self.experiment_id is not None logger.info(f"!lsp|Function Trace ID: {self.function_trace_id}") @@ -885,6 +935,15 @@ def handle_successful_candidate( ) benchmark_tree.add(f"{benchmark_key}: {replay_perf_gain[benchmark_key] * 100:.1f}%") + # Compute React render benchmark if profiler data is available + render_benchmark = None + if original_code_baseline.render_profiles and candidate_result.render_profiles: + from codeflash.languages.javascript.frameworks.react.benchmarking import compare_render_benchmarks + + render_benchmark = compare_render_benchmarks( + original_code_baseline.render_profiles, candidate_result.render_profiles + ) + best_optimization = BestOptimization( candidate=candidate, helper_functions=code_context.helper_functions, @@ -897,6 +956,7 @@ def handle_successful_candidate( winning_replay_benchmarking_test_results=candidate_result.benchmarking_test_results, async_throughput=candidate_result.async_throughput, concurrency_metrics=candidate_result.concurrency_metrics, + render_benchmark=render_benchmark, ) return best_optimization, benchmark_tree @@ -1096,6 +1156,7 @@ def process_single_candidate( best_throughput_until_now=None, original_concurrency_metrics=original_code_baseline.concurrency_metrics, best_concurrency_ratio_until_now=None, + original_render_profiles=original_code_baseline.render_profiles, ) and quantity_of_tests_critic(candidate_result) tree = self.build_runtime_info_tree( @@ -1982,6 +2043,8 @@ def find_and_process_best_optimization( fto_benchmark_timings=self.function_benchmark_timings, total_benchmark_timings=self.total_benchmark_timings, ) + # Extract render metrics for acceptance reason + rb = best_optimization.render_benchmark acceptance_reason = get_acceptance_reason( original_runtime_ns=original_code_baseline.runtime, optimized_runtime_ns=best_optimization.runtime, @@ -1989,7 +2052,21 @@ def find_and_process_best_optimization( optimized_async_throughput=best_optimization.async_throughput, original_concurrency_metrics=original_code_baseline.concurrency_metrics, optimized_concurrency_metrics=best_optimization.concurrency_metrics, + original_render_count=rb.original_render_count if rb else None, + optimized_render_count=rb.optimized_render_count if rb else None, + original_render_duration=rb.original_avg_duration_ms if rb else None, + optimized_render_duration=rb.optimized_avg_duration_ms if rb else None, ) + + # Format render benchmark markdown for PR/explanation + render_benchmark_md = None + if rb: + from codeflash.languages.javascript.frameworks.react.benchmarking import ( + format_render_benchmark_for_pr, + ) + + render_benchmark_md = format_render_benchmark_for_pr(rb) + explanation = Explanation( raw_explanation_message=best_optimization.candidate.explanation, winning_behavior_test_results=best_optimization.winning_behavior_test_results, @@ -2004,6 +2081,7 @@ def find_and_process_best_optimization( original_concurrency_metrics=original_code_baseline.concurrency_metrics, best_concurrency_metrics=best_optimization.concurrency_metrics, acceptance_reason=acceptance_reason, + render_benchmark_markdown=render_benchmark_md, ) self.replace_function_and_helpers_with_optimized_code( @@ -2168,6 +2246,7 @@ def process_review( original_concurrency_metrics=explanation.original_concurrency_metrics, best_concurrency_metrics=explanation.best_concurrency_metrics, acceptance_reason=explanation.acceptance_reason, + render_benchmark_markdown=explanation.render_benchmark_markdown, ) self.log_successful_optimization(new_explanation, generated_tests, exp_type) @@ -2379,6 +2458,11 @@ def establish_original_code_baseline( project_root=self.project_root, ) + # Instrument source with React.Profiler for render measurement + pre_profiler_source = self.instrument_source_with_react_profiler() + if pre_profiler_source is not None: + test_env["CODEFLASH_REACT_PROFILER_MODE"] = "true" + try: benchmarking_results, _ = self.run_and_parse_tests( testing_type=TestingMode.PERFORMANCE, @@ -2391,11 +2475,16 @@ def establish_original_code_baseline( ) logger.debug(f"[BENCHMARK-DONE] Got {len(benchmarking_results.test_results)} benchmark results") finally: + self.restore_source_after_profiler(pre_profiler_source) + test_env.pop("CODEFLASH_REACT_PROFILER_MODE", None) if self.function_to_optimize.is_async: self.write_code_and_helpers( self.function_to_optimize_source_code, original_helper_code, self.function_to_optimize.file_path ) + # Parse React render profiles from performance test stdout + original_render_profiles = self.parse_render_profiles_from_results(benchmarking_results) + console.print( TestResults.report_to_tree( behavioral_results.get_test_pass_fail_report_by_type(), title="Overall test results for original code" @@ -2461,6 +2550,7 @@ def establish_original_code_baseline( line_profile_results=line_profile_results, async_throughput=async_throughput, concurrency_metrics=concurrency_metrics, + render_profiles=original_render_profiles, ), functions_to_remove, ) @@ -2635,6 +2725,11 @@ def run_optimized_candidate( project_root=self.project_root, ) + # Instrument candidate source with React.Profiler for render measurement + pre_profiler_source = self.instrument_source_with_react_profiler() + if pre_profiler_source is not None: + test_env["CODEFLASH_REACT_PROFILER_MODE"] = "true" + try: candidate_benchmarking_results, _ = self.run_and_parse_tests( testing_type=TestingMode.PERFORMANCE, @@ -2645,11 +2740,16 @@ def run_optimized_candidate( enable_coverage=False, ) finally: + self.restore_source_after_profiler(pre_profiler_source) + test_env.pop("CODEFLASH_REACT_PROFILER_MODE", None) # Restore original source if we instrumented it if self.function_to_optimize.is_async and is_python(): self.write_code_and_helpers( candidate_fto_code, candidate_helper_code, self.function_to_optimize.file_path ) + + # Parse React render profiles from candidate performance test stdout + candidate_render_profiles = self.parse_render_profiles_from_results(candidate_benchmarking_results) # Use effective_loop_count which represents the minimum number of timing samples # across all test cases. This is more accurate for JavaScript tests where # capturePerf does internal looping with potentially different iteration counts per test. @@ -2700,6 +2800,7 @@ def run_optimized_candidate( total_candidate_timing=total_candidate_timing, async_throughput=candidate_async_throughput, concurrency_metrics=candidate_concurrency_metrics, + render_profiles=candidate_render_profiles, ) ) diff --git a/codeflash/result/create_pr.py b/codeflash/result/create_pr.py index 0f103db9d..49051df33 100644 --- a/codeflash/result/create_pr.py +++ b/codeflash/result/create_pr.py @@ -325,6 +325,7 @@ def check_create_pr( original_async_throughput=explanation.original_async_throughput, best_async_throughput=explanation.best_async_throughput, language=language, + render_benchmark_markdown=explanation.render_benchmark_markdown, ), existing_tests=existing_tests_source, generated_tests=generated_original_test_source, @@ -380,6 +381,7 @@ def check_create_pr( original_async_throughput=explanation.original_async_throughput, best_async_throughput=explanation.best_async_throughput, language=language, + render_benchmark_markdown=explanation.render_benchmark_markdown, ), existing_tests=existing_tests_source, generated_tests=generated_original_test_source, diff --git a/codeflash/result/critic.py b/codeflash/result/critic.py index e04f01d50..ec1a708e5 100644 --- a/codeflash/result/critic.py +++ b/codeflash/result/critic.py @@ -72,10 +72,11 @@ def speedup_critic( best_throughput_until_now: int | None = None, original_concurrency_metrics: ConcurrencyMetrics | None = None, best_concurrency_ratio_until_now: float | None = None, + original_render_profiles: list | None = None, ) -> bool: """Take in a correct optimized Test Result and decide if the optimization should actually be surfaced to the user. - Evaluates runtime performance, async throughput, and concurrency improvements. + Evaluates runtime performance, async throughput, concurrency improvements, and React render efficiency. For runtime performance: - Ensures the optimization is actually faster than the original code, above the noise floor. @@ -90,6 +91,10 @@ def speedup_critic( For concurrency (when available): - Evaluates concurrency ratio improvements using MIN_CONCURRENCY_IMPROVEMENT_THRESHOLD - Concurrency improvements detect when blocking calls are replaced with non-blocking equivalents + + For React render efficiency (when available): + - Evaluates render count reduction and render duration improvements + - Accepts if render count reduced by >= 20% or render duration improved significantly """ # Runtime performance evaluation noise_floor = 3 * MIN_IMPROVEMENT_THRESHOLD if original_code_runtime < 10000 else MIN_IMPROVEMENT_THRESHOLD @@ -104,6 +109,20 @@ def speedup_critic( # Check runtime comparison with best so far runtime_is_best = best_runtime_until_now is None or candidate_result.best_test_runtime < best_runtime_until_now + # React render efficiency evaluation + render_efficiency_improved = False + if original_render_profiles and candidate_result.render_profiles: + from codeflash.languages.javascript.frameworks.react.benchmarking import compare_render_benchmarks + + benchmark = compare_render_benchmarks(original_render_profiles, candidate_result.render_profiles) + if benchmark: + render_efficiency_improved = render_efficiency_critic( + benchmark.original_render_count, + benchmark.optimized_render_count, + benchmark.original_avg_duration_ms, + benchmark.optimized_avg_duration_ms, + ) + throughput_improved = True # Default to True if no throughput data throughput_is_best = True # Default to True if no throughput data @@ -129,7 +148,10 @@ def speedup_critic( or candidate_result.concurrency_metrics.concurrency_ratio > best_concurrency_ratio_until_now ) - # Accept if ANY of: runtime, throughput, or concurrency improves significantly + # Accept if ANY of: render efficiency, runtime, throughput, or concurrency improves significantly + if render_efficiency_improved: + return True + if original_async_throughput is not None and candidate_result.async_throughput is not None: throughput_acceptance = throughput_improved and throughput_is_best runtime_acceptance = runtime_improved and runtime_is_best @@ -146,11 +168,15 @@ def get_acceptance_reason( optimized_async_throughput: int | None = None, original_concurrency_metrics: ConcurrencyMetrics | None = None, optimized_concurrency_metrics: ConcurrencyMetrics | None = None, + original_render_count: int | None = None, + optimized_render_count: int | None = None, + original_render_duration: float | None = None, + optimized_render_duration: float | None = None, ) -> AcceptanceReason: """Determine why an optimization was accepted. Returns the primary reason for acceptance, with priority: - concurrency > throughput > runtime (for async code). + render_count > concurrency > throughput > runtime. """ noise_floor = 3 * MIN_IMPROVEMENT_THRESHOLD if original_runtime_ns < 10000 else MIN_IMPROVEMENT_THRESHOLD if env_utils.is_ci(): @@ -159,6 +185,18 @@ def get_acceptance_reason( perf_gain = performance_gain(original_runtime_ns=original_runtime_ns, optimized_runtime_ns=optimized_runtime_ns) runtime_improved = perf_gain > noise_floor + # Check React render efficiency + render_improved = False + if ( + original_render_count is not None + and optimized_render_count is not None + and original_render_duration is not None + and optimized_render_duration is not None + ): + render_improved = render_efficiency_critic( + original_render_count, optimized_render_count, original_render_duration, optimized_render_duration + ) + throughput_improved = False if ( original_async_throughput is not None @@ -175,7 +213,10 @@ def get_acceptance_reason( conc_gain = concurrency_gain(original_concurrency_metrics, optimized_concurrency_metrics) concurrency_improved = conc_gain > MIN_CONCURRENCY_IMPROVEMENT_THRESHOLD - # Return reason with priority: concurrency > throughput > runtime + # Return reason with priority: render_count > concurrency > throughput > runtime + if render_improved: + return AcceptanceReason.RENDER_COUNT + if original_async_throughput is not None and optimized_async_throughput is not None: if concurrency_improved: return AcceptanceReason.CONCURRENCY diff --git a/packages/codeflash/runtime/capture.js b/packages/codeflash/runtime/capture.js index 4db716367..918e6682d 100644 --- a/packages/codeflash/runtime/capture.js +++ b/packages/codeflash/runtime/capture.js @@ -1068,6 +1068,11 @@ function captureRender(funcName, lineId, renderFn, Component, ...createElementAr * Between loop iterations the previous render result is unmounted to keep * the DOM clean and ensure each iteration starts from the same state. * + * When CODEFLASH_REACT_PROFILER_MODE is enabled, skips the Benchmark.jsx + * mount/unmount cycling and renders once normally. The React.Profiler + * instrumentation in the source code emits timing data automatically via + * stdout markers, so no additional benchmarking is needed. + * * @param {string} funcName - Name of the component being tested (static) * @param {string} lineId - Line number identifier in test file (static) * @param {Function} renderFn - The render function from @testing-library/react @@ -1077,6 +1082,15 @@ function captureRender(funcName, lineId, renderFn, Component, ...createElementAr * @throws {Error} - Re-throws any error from rendering */ function captureRenderPerf(funcName, lineId, renderFn, Component, ...createElementArgs) { + // In Profiler mode, skip Benchmark.jsx cycling. The React.Profiler wrapper + // in the source code emits render markers automatically on every render. + // Just render once normally so test assertions pass. + if (process.env.CODEFLASH_REACT_PROFILER_MODE === 'true') { + const React = _getReact(); + const element = React.createElement(Component, ...createElementArgs); + return Promise.resolve(renderFn(element)); + } + const runBenchmark = require('./react-benchmark/run'); const { testClassName, safeModulePath, safeTestFunctionName } = _getTestContext(); From 1c3d8aa04b8f6ddcf37b48de47a095b7547c239c Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Wed, 11 Mar 2026 01:00:55 +0530 Subject: [PATCH 55/57] render profile add to all nodes of pipeline --- codeflash/api/aiservice.py | 4 ++ .../frameworks/react/benchmarking.py | 28 +++++++- .../javascript/frameworks/react/testgen.py | 60 ++++++++++++++-- codeflash/languages/javascript/parse.py | 31 ++++++++ codeflash/languages/javascript/support.py | 6 ++ codeflash/languages/javascript/test_runner.py | 65 +++++++++++++++++ codeflash/models/models.py | 2 + codeflash/optimization/function_optimizer.py | 47 ++++++++++++- codeflash/result/critic.py | 35 ++++++++-- codeflash/verification/test_runner.py | 18 ++--- codeflash/verification/verifier.py | 4 ++ packages/codeflash/runtime/capture.js | 70 ++++++++++++++++++- 12 files changed, 340 insertions(+), 30 deletions(-) diff --git a/codeflash/api/aiservice.py b/codeflash/api/aiservice.py index a144a9dd3..bed07a7f4 100644 --- a/codeflash/api/aiservice.py +++ b/codeflash/api/aiservice.py @@ -737,6 +737,8 @@ def generate_regression_tests( language_version: str | None = None, module_system: str | None = None, is_numerical_code: bool | None = None, + is_react_component: bool = False, + react_context: str | None = None, ) -> tuple[str, str, str] | None: """Generate regression tests for the given function by making a request to the Django endpoint. @@ -786,6 +788,8 @@ def generate_regression_tests( "is_async": function_to_optimize.is_async, "call_sequence": self.get_next_sequence(), "is_numerical_code": is_numerical_code, + "is_react_component": is_react_component, + "react_context": react_context, } # Add language-specific version fields diff --git a/codeflash/languages/javascript/frameworks/react/benchmarking.py b/codeflash/languages/javascript/frameworks/react/benchmarking.py index 0ef29f648..6dfa5c764 100644 --- a/codeflash/languages/javascript/frameworks/react/benchmarking.py +++ b/codeflash/languages/javascript/frameworks/react/benchmarking.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from codeflash.languages.javascript.parse import RenderProfile + from codeflash.languages.javascript.parse import DomMutationProfile, RenderProfile @dataclass(frozen=True) @@ -22,6 +22,8 @@ class RenderBenchmark: optimized_render_count: int original_avg_duration_ms: float optimized_avg_duration_ms: float + original_dom_mutations: int = 0 + optimized_dom_mutations: int = 0 @property def render_count_reduction_pct(self) -> float: @@ -44,9 +46,19 @@ def render_speedup_x(self) -> float: return 0.0 return self.original_avg_duration_ms / self.optimized_avg_duration_ms + @property + def dom_mutation_reduction_pct(self) -> float: + """Percentage reduction in DOM mutations (0-100).""" + if self.original_dom_mutations == 0: + return 0.0 + return (self.original_dom_mutations - self.optimized_dom_mutations) / self.original_dom_mutations * 100 + def compare_render_benchmarks( - original_profiles: list[RenderProfile], optimized_profiles: list[RenderProfile] + original_profiles: list[RenderProfile], + optimized_profiles: list[RenderProfile], + original_dom_mutations: list[DomMutationProfile] | None = None, + optimized_dom_mutations: list[DomMutationProfile] | None = None, ) -> RenderBenchmark | None: """Compare original and optimized render profiles. @@ -69,12 +81,18 @@ def compare_render_benchmarks( opt_durations = [p.actual_duration_ms for p in optimized_profiles] opt_avg_duration = sum(opt_durations) / len(opt_durations) if opt_durations else 0.0 + # Aggregate DOM mutation counts + orig_dom = sum(p.mutation_count for p in original_dom_mutations) if original_dom_mutations else 0 + opt_dom = sum(p.mutation_count for p in optimized_dom_mutations) if optimized_dom_mutations else 0 + return RenderBenchmark( component_name=component_name, original_render_count=orig_count, optimized_render_count=opt_count, original_avg_duration_ms=orig_avg_duration, optimized_avg_duration_ms=opt_avg_duration, + original_dom_mutations=orig_dom, + optimized_dom_mutations=opt_dom, ) @@ -92,6 +110,12 @@ def format_render_benchmark_for_pr(benchmark: RenderBenchmark) -> str: f"| {benchmark.duration_reduction_pct:.1f}% faster |", ] + if benchmark.original_dom_mutations > 0 or benchmark.optimized_dom_mutations > 0: + lines.append( + f"| DOM mutations | {benchmark.original_dom_mutations} | {benchmark.optimized_dom_mutations} " + f"| {benchmark.dom_mutation_reduction_pct:.1f}% fewer |" + ) + if benchmark.render_speedup_x > 1: lines.append(f"\nRender time improved **{benchmark.render_speedup_x:.1f}x**.") diff --git a/codeflash/languages/javascript/frameworks/react/testgen.py b/codeflash/languages/javascript/frameworks/react/testgen.py index be09e858e..199d225a9 100644 --- a/codeflash/languages/javascript/frameworks/react/testgen.py +++ b/codeflash/languages/javascript/frameworks/react/testgen.py @@ -89,7 +89,9 @@ def post_process_react_tests(test_source: str, component_info: ReactComponentInf Ensures: - @testing-library/react imports are present - act() wrapping for state updates - - Proper cleanup + - cleanup import when unmount is used + - Fake timers for debounce/throttle tests + - user-event import for interaction tests """ result = test_source @@ -97,15 +99,61 @@ def post_process_react_tests(test_source: str, component_info: ReactComponentInf if "@testing-library/react" not in result: result = "import { render, screen, act } from '@testing-library/react';\n" + result - # Ensure act import if state updates are detected - if "act(" in result and "import" in result and "act" not in result.split("from '@testing-library/react'")[0]: - result = result.replace("from '@testing-library/react'", "act, " + "from '@testing-library/react'", 1) + # Ensure act is in the @testing-library/react import if act() is used in the test + if "act(" in result: + match = re.search(r"import\s*\{([^}]+)\}\s*from\s*['\"]@testing-library/react['\"]", result) + if match: + imports = match.group(1) + if "act" not in imports: + result = result.replace(match.group(0), match.group(0).replace("{" + imports + "}", "{" + imports + ", act}")) + + # Ensure cleanup import if unmount is called + if "unmount" in result: + match = re.search(r"import\s*\{([^}]+)\}\s*from\s*['\"]@testing-library/react['\"]", result) + if match: + imports = match.group(1) + if "cleanup" not in imports: + result = result.replace(match.group(0), match.group(0).replace("{" + imports + "}", "{" + imports + ", cleanup}")) + + # Ensure fake timers for debounce/throttle tests + if re.search(r"jest\.advanceTimersByTime|jest\.runAllTimers|jest\.runOnlyPendingTimers|vi\.advanceTimersByTime", result): + if "useFakeTimers" not in result: + # Inject jest.useFakeTimers() at the start of beforeEach or before first describe + before_each_match = re.search(r"(beforeEach\s*\(\s*(?:async\s*)?\(\)\s*=>\s*\{)", result) + if before_each_match: + result = result.replace( + before_each_match.group(0), + before_each_match.group(0) + "\n jest.useFakeTimers();", + ) + # Also add afterEach to restore real timers if not present + if "useRealTimers" not in result: + after_each_match = re.search(r"(afterEach\s*\(\s*(?:async\s*)?\(\)\s*=>\s*\{)", result) + if after_each_match: + result = result.replace( + after_each_match.group(0), + after_each_match.group(0) + "\n jest.useRealTimers();", + ) + else: + # Add afterEach block after the beforeEach block + result = re.sub( + r"(beforeEach\s*\([^)]*\)\s*=>\s*\{[^}]*\}\s*\);?\s*\n)", + r"\1\n afterEach(() => {\n jest.useRealTimers();\n });\n", + result, + count=1, + ) + else: + # No beforeEach — inject before first describe/it block + result = re.sub( + r"(describe\s*\()", + "beforeEach(() => {\n jest.useFakeTimers();\n});\n\nafterEach(() => {\n jest.useRealTimers();\n});\n\n\\1", + result, + count=1, + ) # Ensure user-event import if user interactions are tested if ( - "click" in result.lower() or "type" in result.lower() or "userEvent" in result + "userEvent" in result or "user-event" in result ) and "@testing-library/user-event" not in result: - # Add user-event import after testing-library import result = re.sub( r"(import .+ from '@testing-library/react';?\n)", r"\1import userEvent from '@testing-library/user-event';\n", diff --git a/codeflash/languages/javascript/parse.py b/codeflash/languages/javascript/parse.py index 2178b3cdd..8020ed85b 100644 --- a/codeflash/languages/javascript/parse.py +++ b/codeflash/languages/javascript/parse.py @@ -37,6 +37,10 @@ # Format: !######REACT_RENDER:{component}:{phase}:{actualDuration}:{baseDuration}:{renderCount}######! REACT_RENDER_MARKER_PATTERN = re.compile(r"!######REACT_RENDER:([^:]+):([^:]+):([^:]+):([^:]+):(\d+)######!") +# DOM mutation marker pattern +# Format: !######DOM_MUTATIONS:{component}:{mutationCount}######! +DOM_MUTATION_MARKER_PATTERN = re.compile(r"!######DOM_MUTATIONS:([^:]+):(\d+)######!") + @dataclass(frozen=True) class RenderProfile: @@ -71,6 +75,33 @@ def parse_react_render_markers(stdout: str) -> list[RenderProfile]: return profiles +@dataclass(frozen=True) +class DomMutationProfile: + """Parsed DOM mutation count from a single marker.""" + + component_name: str + mutation_count: int + + +def parse_dom_mutation_markers(stdout: str) -> list[DomMutationProfile]: + """Parse DOM mutation markers from test output. + + Returns a list of DomMutationProfile instances, one per marker found. + """ + profiles: list[DomMutationProfile] = [] + for match in DOM_MUTATION_MARKER_PATTERN.finditer(stdout): + try: + profiles.append( + DomMutationProfile( + component_name=match.group(1), + mutation_count=int(match.group(2)), + ) + ) + except (ValueError, IndexError) as e: + logger.debug("Failed to parse DOM mutation marker: %s", e) + return profiles + + def _extract_jest_console_output(suite_elem: Any) -> str: """Extract console output from Jest's JUnit XML system-out element. diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index 3ffd3cc6b..113b08870 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -2372,6 +2372,7 @@ def run_behavioral_tests( enable_coverage: bool = False, candidate_index: int = 0, test_framework: str | None = None, + is_react_component: bool = False, ) -> tuple[Path, Any, Path | None, Path | None]: """Run behavioral tests using the detected test framework. @@ -2416,6 +2417,7 @@ def run_behavioral_tests( project_root=project_root, enable_coverage=enable_coverage, candidate_index=candidate_index, + is_react_component=is_react_component, ) def run_benchmarking_tests( @@ -2429,6 +2431,7 @@ def run_benchmarking_tests( max_loops: int = 100_000, target_duration_seconds: float = 10.0, test_framework: str | None = None, + is_react_component: bool = False, ) -> tuple[Path, Any]: """Run benchmarking tests using the detected test framework. @@ -2478,6 +2481,7 @@ def run_benchmarking_tests( min_loops=min_loops, max_loops=max_loops, target_duration_ms=int(target_duration_seconds * 1000), + is_react_component=is_react_component, ) def run_line_profile_tests( @@ -2489,6 +2493,7 @@ def run_line_profile_tests( project_root: Path | None = None, line_profile_output_file: Path | None = None, test_framework: str | None = None, + is_react_component: bool = False, ) -> tuple[Path, Any]: """Run tests for line profiling using the detected test framework. @@ -2530,6 +2535,7 @@ def run_line_profile_tests( timeout=timeout, project_root=project_root, line_profile_output_file=line_profile_output_file, + is_react_component=is_react_component, ) diff --git a/codeflash/languages/javascript/test_runner.py b/codeflash/languages/javascript/test_runner.py index b6fd151b1..50ca1a6ee 100644 --- a/codeflash/languages/javascript/test_runner.py +++ b/codeflash/languages/javascript/test_runner.py @@ -191,6 +191,44 @@ def _create_codeflash_tsconfig(project_root: Path) -> Path: return codeflash_tsconfig_path +def _has_jsdom_dependency(project_root: Path) -> bool: + """Check if jest-environment-jsdom is available in the project.""" + package_json = project_root / "package.json" + if not package_json.exists(): + return False + + try: + content = json.loads(package_json.read_text()) + deps = {**content.get("dependencies", {}), **content.get("devDependencies", {})} + return "jest-environment-jsdom" in deps + except (json.JSONDecodeError, OSError): + return False + + +def _ensure_jsdom_for_react(project_root: Path) -> None: + """Ensure jest-environment-jsdom is installed for React component testing.""" + if _has_jsdom_dependency(project_root): + return + + # Also check if it's installed in node_modules even without being in package.json + if (project_root / "node_modules" / "jest-environment-jsdom").exists(): + return + + logger.warning( + "jest-environment-jsdom is required for React component testing but was not found. " + "Install it with: npm install --save-dev jest-environment-jsdom" + ) + install_cmd = get_package_install_command(project_root, "jest-environment-jsdom", dev=True) + try: + result = subprocess.run(install_cmd, check=False, cwd=project_root, capture_output=True, text=True, timeout=120) + if result.returncode == 0: + logger.debug("Installed jest-environment-jsdom") + return + logger.warning(f"Failed to install jest-environment-jsdom: {result.stderr}") + except Exception as e: + logger.warning(f"Error installing jest-environment-jsdom: {e}") + + def _has_ts_jest_dependency(project_root: Path) -> bool: """Check if the project has ts-jest as a dependency. @@ -770,6 +808,7 @@ def run_jest_behavioral_tests( project_root: Path | None = None, enable_coverage: bool = False, candidate_index: int = 0, + is_react_component: bool = False, ) -> tuple[Path, subprocess.CompletedProcess[str], Path | None, Path | None]: """Run Jest tests and return results in a format compatible with pytest output. @@ -802,6 +841,10 @@ def run_jest_behavioral_tests( # Ensure the codeflash npm package is installed _ensure_runtime_files(effective_cwd) + # Ensure jsdom is available for React component testing + if is_react_component: + _ensure_jsdom_for_react(effective_cwd) + # Coverage output directory coverage_dir = get_run_tmp_file(Path("jest_coverage")) coverage_json_path = coverage_dir / "coverage-final.json" if enable_coverage else None @@ -822,6 +865,10 @@ def run_jest_behavioral_tests( if jest_config: jest_cmd.append(f"--config={jest_config}") + # React components need jsdom for DOM APIs used by @testing-library/react + if is_react_component: + jest_cmd.append("--testEnvironment=jsdom") + # Add coverage flags if enabled if enable_coverage: jest_cmd.extend(["--coverage", "--coverageReporters=json", f"--coverageDirectory={coverage_dir}"]) @@ -1016,6 +1063,7 @@ def run_jest_benchmarking_tests( max_loops: int = 100, target_duration_ms: int = 10_000, # 10 seconds for benchmarking tests stability_check: bool = True, + is_react_component: bool = False, ) -> tuple[Path, subprocess.CompletedProcess[str]]: """Run Jest benchmarking tests with in-process session-level looping. @@ -1056,6 +1104,10 @@ def run_jest_benchmarking_tests( # Ensure the codeflash npm package is installed _ensure_runtime_files(effective_cwd) + # Ensure jsdom is available for React component testing + if is_react_component: + _ensure_jsdom_for_react(effective_cwd) + # Detect Jest version for logging jest_major_version = _get_jest_major_version(effective_cwd) if jest_major_version: @@ -1079,6 +1131,10 @@ def run_jest_benchmarking_tests( if jest_config: jest_cmd.append(f"--config={jest_config}") + # React components need jsdom for DOM APIs used by @testing-library/react + if is_react_component: + jest_cmd.append("--testEnvironment=jsdom") + if test_files: jest_cmd.append("--runTestsByPath") resolved_test_files = [str(Path(f).resolve()) for f in test_files] @@ -1195,6 +1251,7 @@ def run_jest_line_profile_tests( timeout: int | None = None, project_root: Path | None = None, line_profile_output_file: Path | None = None, + is_react_component: bool = False, ) -> tuple[Path, subprocess.CompletedProcess[str]]: """Run Jest tests for line profiling. @@ -1234,6 +1291,10 @@ def run_jest_line_profile_tests( # Ensure the codeflash npm package is installed _ensure_runtime_files(effective_cwd) + # Ensure jsdom is available for React component testing + if is_react_component: + _ensure_jsdom_for_react(effective_cwd) + # Build Jest command for line profiling - simple run without benchmarking loops jest_cmd = [ "npx", @@ -1250,6 +1311,10 @@ def run_jest_line_profile_tests( if jest_config: jest_cmd.append(f"--config={jest_config}") + # React components need jsdom for DOM APIs used by @testing-library/react + if is_react_component: + jest_cmd.append("--testEnvironment=jsdom") + if test_files: jest_cmd.append("--runTestsByPath") resolved_test_files = [str(Path(f).resolve()) for f in test_files] diff --git a/codeflash/models/models.py b/codeflash/models/models.py index dfea5ddf4..2f3ef0598 100644 --- a/codeflash/models/models.py +++ b/codeflash/models/models.py @@ -402,6 +402,7 @@ class OptimizedCandidateResult(BaseModel): async_throughput: Optional[int] = None concurrency_metrics: Optional[ConcurrencyMetrics] = None render_profiles: Optional[list[Any]] = None + dom_mutations: Optional[list[Any]] = None class GeneratedTests(BaseModel): @@ -636,6 +637,7 @@ class OriginalCodeBaseline(BaseModel): async_throughput: Optional[int] = None concurrency_metrics: Optional[ConcurrencyMetrics] = None render_profiles: Optional[list[Any]] = None + dom_mutations: Optional[list[Any]] = None class CoverageStatus(Enum): diff --git a/codeflash/optimization/function_optimizer.py b/codeflash/optimization/function_optimizer.py index 150f11b11..f5c0c2581 100644 --- a/codeflash/optimization/function_optimizer.py +++ b/codeflash/optimization/function_optimizer.py @@ -546,6 +546,21 @@ def parse_render_profiles_from_results(self, test_results: TestResults) -> list logger.debug("Failed to parse React render markers", exc_info=True) return None + def parse_dom_mutations_from_results(self, test_results: TestResults) -> list | None: + """Parse DOM mutation markers from test stdout.""" + if not self.is_react_component or not test_results.perf_stdout: + return None + try: + from codeflash.languages.javascript.parse import parse_dom_mutation_markers + + profiles = parse_dom_mutation_markers(test_results.perf_stdout) + if profiles: + logger.debug(f"Parsed {len(profiles)} DOM mutation profiles from test output") + return profiles + except Exception: + logger.debug("Failed to parse DOM mutation markers", exc_info=True) + return None + def can_be_optimized(self) -> Result[tuple[bool, CodeOptimizationContext, dict[Path, str]], str]: should_run_experiment = self.experiment_id is not None logger.info(f"!lsp|Function Trace ID: {self.function_trace_id}") @@ -941,7 +956,10 @@ def handle_successful_candidate( from codeflash.languages.javascript.frameworks.react.benchmarking import compare_render_benchmarks render_benchmark = compare_render_benchmarks( - original_code_baseline.render_profiles, candidate_result.render_profiles + original_code_baseline.render_profiles, + candidate_result.render_profiles, + original_dom_mutations=original_code_baseline.dom_mutations, + optimized_dom_mutations=candidate_result.dom_mutations, ) best_optimization = BestOptimization( @@ -2056,6 +2074,8 @@ def find_and_process_best_optimization( optimized_render_count=rb.optimized_render_count if rb else None, original_render_duration=rb.original_avg_duration_ms if rb else None, optimized_render_duration=rb.optimized_avg_duration_ms if rb else None, + original_dom_mutations=rb.original_dom_mutations if rb else 0, + optimized_dom_mutations=rb.optimized_dom_mutations if rb else 0, ) # Format render benchmark markdown for PR/explanation @@ -2414,6 +2434,7 @@ def establish_original_code_baseline( testing_time=total_looping_time, enable_coverage=True, code_context=code_context, + is_react_component=self.is_react_component, ) finally: # Remove codeflash capture @@ -2472,6 +2493,7 @@ def establish_original_code_baseline( testing_time=total_looping_time, enable_coverage=False, code_context=code_context, + is_react_component=self.is_react_component, ) logger.debug(f"[BENCHMARK-DONE] Got {len(benchmarking_results.test_results)} benchmark results") finally: @@ -2482,8 +2504,9 @@ def establish_original_code_baseline( self.function_to_optimize_source_code, original_helper_code, self.function_to_optimize.file_path ) - # Parse React render profiles from performance test stdout + # Parse React render profiles and DOM mutation data from performance test stdout original_render_profiles = self.parse_render_profiles_from_results(benchmarking_results) + original_dom_mutations = self.parse_dom_mutations_from_results(benchmarking_results) console.print( TestResults.report_to_tree( @@ -2551,6 +2574,7 @@ def establish_original_code_baseline( async_throughput=async_throughput, concurrency_metrics=concurrency_metrics, render_profiles=original_render_profiles, + dom_mutations=original_dom_mutations, ), functions_to_remove, ) @@ -2661,6 +2685,7 @@ def run_optimized_candidate( optimization_iteration=optimization_candidate_index, testing_time=total_looping_time, enable_coverage=False, + is_react_component=self.is_react_component, ) # Remove instrumentation finally: @@ -2738,6 +2763,7 @@ def run_optimized_candidate( optimization_iteration=optimization_candidate_index, testing_time=total_looping_time, enable_coverage=False, + is_react_component=self.is_react_component, ) finally: self.restore_source_after_profiler(pre_profiler_source) @@ -2748,8 +2774,9 @@ def run_optimized_candidate( candidate_fto_code, candidate_helper_code, self.function_to_optimize.file_path ) - # Parse React render profiles from candidate performance test stdout + # Parse React render profiles and DOM mutation data from candidate performance test stdout candidate_render_profiles = self.parse_render_profiles_from_results(candidate_benchmarking_results) + candidate_dom_mutations = self.parse_dom_mutations_from_results(candidate_benchmarking_results) # Use effective_loop_count which represents the minimum number of timing samples # across all test cases. This is more accurate for JavaScript tests where # capturePerf does internal looping with potentially different iteration counts per test. @@ -2801,6 +2828,7 @@ def run_optimized_candidate( async_throughput=candidate_async_throughput, concurrency_metrics=candidate_concurrency_metrics, render_profiles=candidate_render_profiles, + dom_mutations=candidate_dom_mutations, ) ) @@ -2817,6 +2845,7 @@ def run_and_parse_tests( pytest_max_loops: int = 250, code_context: CodeOptimizationContext | None = None, line_profiler_output_file: Path | None = None, + is_react_component: bool = False, ) -> tuple[TestResults | dict, CoverageData | None]: coverage_database_file = None coverage_config_file = None @@ -2831,6 +2860,7 @@ def run_and_parse_tests( enable_coverage=enable_coverage, js_project_root=self.test_cfg.js_project_root, candidate_index=optimization_iteration, + is_react_component=is_react_component, ) elif testing_type == TestingMode.LINE_PROFILE: result_file_path, run_result = run_line_profile_tests( @@ -2843,6 +2873,7 @@ def run_and_parse_tests( test_framework=self.test_cfg.test_framework, js_project_root=self.test_cfg.js_project_root, line_profiler_output_file=line_profiler_output_file, + is_react_component=is_react_component, ) elif testing_type == TestingMode.PERFORMANCE: result_file_path, run_result = run_benchmarking_tests( @@ -2856,6 +2887,7 @@ def run_and_parse_tests( pytest_max_loops=pytest_max_loops, test_framework=self.test_cfg.test_framework, js_project_root=self.test_cfg.js_project_root, + is_react_component=is_react_component, ) else: msg = f"Unexpected testing type: {testing_type}" @@ -2923,6 +2955,13 @@ def submit_test_generation_tasks( generated_test_paths: list[Path], generated_perf_test_paths: list[Path], ) -> list[concurrent.futures.Future]: + metadata = self.function_to_optimize.metadata or {} + react_context = metadata.get("react_context") + if react_context and not isinstance(react_context, str): + import json as _json + + react_context = _json.dumps(react_context) + return [ executor.submit( generate_tests, @@ -2938,6 +2977,8 @@ def submit_test_generation_tasks( test_path, test_perf_path, self.is_numerical_code, + self.is_react_component, + react_context, ) for test_index, (test_path, test_perf_path) in enumerate( zip(generated_test_paths, generated_perf_test_paths) diff --git a/codeflash/result/critic.py b/codeflash/result/critic.py index ec1a708e5..f6b9b2b13 100644 --- a/codeflash/result/critic.py +++ b/codeflash/result/critic.py @@ -121,6 +121,8 @@ def speedup_critic( benchmark.optimized_render_count, benchmark.original_avg_duration_ms, benchmark.optimized_avg_duration_ms, + original_dom_mutations=benchmark.original_dom_mutations, + optimized_dom_mutations=benchmark.optimized_dom_mutations, ) throughput_improved = True # Default to True if no throughput data @@ -172,6 +174,8 @@ def get_acceptance_reason( optimized_render_count: int | None = None, original_render_duration: float | None = None, optimized_render_duration: float | None = None, + original_dom_mutations: int = 0, + optimized_dom_mutations: int = 0, ) -> AcceptanceReason: """Determine why an optimization was accepted. @@ -194,7 +198,12 @@ def get_acceptance_reason( and optimized_render_duration is not None ): render_improved = render_efficiency_critic( - original_render_count, optimized_render_count, original_render_duration, optimized_render_duration + original_render_count, + optimized_render_count, + original_render_duration, + optimized_render_duration, + original_dom_mutations=original_dom_mutations, + optimized_dom_mutations=optimized_dom_mutations, ) throughput_improved = False @@ -256,26 +265,34 @@ def coverage_critic(original_code_coverage: CoverageData | None) -> bool: MIN_RENDER_COUNT_REDUCTION_PCT = 0.20 # 20% +MIN_DOM_MUTATION_REDUCTION_PCT = 0.20 # 20% + + def render_efficiency_critic( original_render_count: int, optimized_render_count: int, original_render_duration: float, optimized_render_duration: float, best_render_count_until_now: int | None = None, + original_dom_mutations: int = 0, + optimized_dom_mutations: int = 0, ) -> bool: - """Evaluate whether a React optimization reduces re-renders or render time sufficiently. + """Evaluate whether a React optimization reduces re-renders, render time, or DOM mutations sufficiently. Accepts if: - Render count is reduced by >= 20% - OR render duration is reduced by >= MIN_IMPROVEMENT_THRESHOLD + - OR DOM mutations are reduced by >= 20% - AND the candidate is the best seen so far """ - if original_render_count == 0: + if original_render_count == 0 and original_dom_mutations == 0: return False # Check render count reduction - count_reduction = (original_render_count - optimized_render_count) / original_render_count - count_improved = count_reduction >= MIN_RENDER_COUNT_REDUCTION_PCT + count_improved = False + if original_render_count > 0: + count_reduction = (original_render_count - optimized_render_count) / original_render_count + count_improved = count_reduction >= MIN_RENDER_COUNT_REDUCTION_PCT # Check render duration reduction duration_improved = False @@ -283,7 +300,13 @@ def render_efficiency_critic( duration_gain = (original_render_duration - optimized_render_duration) / original_render_duration duration_improved = duration_gain > MIN_IMPROVEMENT_THRESHOLD + # Check DOM mutation reduction + dom_mutations_improved = False + if original_dom_mutations > 0: + dom_reduction = (original_dom_mutations - optimized_dom_mutations) / original_dom_mutations + dom_mutations_improved = dom_reduction >= MIN_DOM_MUTATION_REDUCTION_PCT + # Check if this is the best candidate so far is_best = best_render_count_until_now is None or optimized_render_count <= best_render_count_until_now - return (count_improved or duration_improved) and is_best + return (count_improved or duration_improved or dom_mutations_improved) and is_best diff --git a/codeflash/verification/test_runner.py b/codeflash/verification/test_runner.py index d4c64879c..89ae1db42 100644 --- a/codeflash/verification/test_runner.py +++ b/codeflash/verification/test_runner.py @@ -126,6 +126,7 @@ def run_behavioral_tests( enable_coverage: bool = False, js_project_root: Path | None = None, candidate_index: int = 0, + is_react_component: bool = False, ) -> tuple[Path, subprocess.CompletedProcess, Path | None, Path | None]: """Run behavioral tests with optional coverage.""" # Check if there's a language support for this test framework that implements run_behavioral_tests @@ -139,6 +140,7 @@ def run_behavioral_tests( project_root=js_project_root, enable_coverage=enable_coverage, candidate_index=candidate_index, + is_react_component=is_react_component, ) if is_python(): test_files: list[str] = [] @@ -262,6 +264,7 @@ def run_line_profile_tests( pytest_max_loops: int = 100_000, js_project_root: Path | None = None, line_profiler_output_file: Path | None = None, + is_react_component: bool = False, ) -> tuple[Path, subprocess.CompletedProcess]: # Check if there's a language support for this test framework that implements run_line_profile_tests language_support = get_language_support_by_framework(test_framework) @@ -273,6 +276,7 @@ def run_line_profile_tests( timeout=pytest_timeout, project_root=js_project_root, line_profile_output_file=line_profiler_output_file, + is_react_component=is_react_component, ) if is_python(): # pytest runs both pytest and unittest tests pytest_cmd_list = ( @@ -324,23 +328,12 @@ def run_benchmarking_tests( pytest_min_loops: int = 5, pytest_max_loops: int = 100_000, js_project_root: Path | None = None, + is_react_component: bool = False, ) -> tuple[Path, subprocess.CompletedProcess]: logger.debug(f"run_benchmarking_tests called: framework={test_framework}, num_files={len(test_paths.test_files)}") # Check if there's a language support for this test framework that implements run_benchmarking_tests language_support = get_language_support_by_framework(test_framework) if language_support is not None and hasattr(language_support, "run_benchmarking_tests"): - # needs_warmup = test_framework == "jest" - # if needs_warmup: - # language_support.run_benchmarking_tests( - # test_paths=test_paths, - # test_env=test_env, - # cwd=cwd, - # timeout=pytest_timeout, - # project_root=js_project_root, - # min_loops=1, - # max_loops=1, - # target_duration_seconds=pytest_target_runtime_seconds, - # ) return language_support.run_benchmarking_tests( test_paths=test_paths, test_env=test_env, @@ -350,6 +343,7 @@ def run_benchmarking_tests( min_loops=pytest_min_loops, max_loops=pytest_max_loops, target_duration_seconds=pytest_target_runtime_seconds, + is_react_component=is_react_component, ) if is_python(): # pytest runs both pytest and unittest tests pytest_cmd_list = ( diff --git a/codeflash/verification/verifier.py b/codeflash/verification/verifier.py index 78bd2e4ab..cb6fb11f3 100644 --- a/codeflash/verification/verifier.py +++ b/codeflash/verification/verifier.py @@ -29,6 +29,8 @@ def generate_tests( test_path: Path, test_perf_path: Path, is_numerical_code: bool | None = None, + is_react_component: bool = False, + react_context: str | None = None, ) -> tuple[str, str, str, Path, Path] | None: # TODO: Sometimes this recreates the original Class definition. This overrides and messes up the original # class import. Remove the recreation of the class definition @@ -70,6 +72,8 @@ def generate_tests( language=function_to_optimize.language, module_system=project_module_system, is_numerical_code=is_numerical_code, + is_react_component=is_react_component, + react_context=react_context, ) if response and isinstance(response, tuple) and len(response) == 3: generated_test_source, instrumented_behavior_test_source, instrumented_perf_test_source = response diff --git a/packages/codeflash/runtime/capture.js b/packages/codeflash/runtime/capture.js index 918e6682d..5100650f0 100644 --- a/packages/codeflash/runtime/capture.js +++ b/packages/codeflash/runtime/capture.js @@ -283,6 +283,9 @@ if (RANDOM_SEED !== 0) { let currentTestName = null; let currentTestPath = null; // Test file path from Jest +// Track active DOM MutationObservers for automatic cleanup/emission in afterEach +let _activeMutationObservers = []; + // Invocation counter map: tracks how many times each testId has been seen // Key: testId (testModule:testClass:testFunction:lineId:loopIndex) // Value: count (starts at 0, increments each time same key is seen) @@ -665,6 +668,28 @@ function capture(funcName, lineId, fn, ...args) { * @throws {Error} - Re-throws any error from the function */ function capturePerf(funcName, lineId, fn, ...args) { + // In React Profiler mode, skip the benchmarking loop. Just call the function + // once and let React.Profiler instrumentation in the source capture render metrics. + if (process.env.CODEFLASH_REACT_PROFILER_MODE === 'true') { + const result = fn(...args); + + // Set up DOM mutation counting for React profiler mode + const MutationObserverCls = globalThis.MutationObserver; + if (MutationObserverCls && result && result.container) { + let mutationCount = 0; + const observer = new MutationObserverCls((mutations) => { + mutationCount += mutations.length; + }); + observer.observe(result.container, { + childList: true, subtree: true, attributes: true + }); + const entry = { funcName, observer, getMutationCount: () => mutationCount }; + _activeMutationObservers.push(entry); + } + + return result; + } + // Check if we should skip looping entirely (shared time budget exceeded) const shouldLoop = getPerfLoopCount() > 1 && !checkSharedTimeLimit(); @@ -1088,7 +1113,34 @@ function captureRenderPerf(funcName, lineId, renderFn, Component, ...createEleme if (process.env.CODEFLASH_REACT_PROFILER_MODE === 'true') { const React = _getReact(); const element = React.createElement(Component, ...createElementArgs); - return Promise.resolve(renderFn(element)); + + const result = renderFn(element); + + // Set up DOM mutation counting after initial render + const MutationObserverCls = globalThis.MutationObserver; + if (MutationObserverCls && result && result.container) { + let mutationCount = 0; + const observer = new MutationObserverCls((mutations) => { + mutationCount += mutations.length; + }); + observer.observe(result.container, { + childList: true, subtree: true, attributes: true + }); + + // Track for automatic emission in afterEach + const entry = { funcName, observer, getMutationCount: () => mutationCount }; + _activeMutationObservers.push(entry); + + // Attach cleanup helper so tests can also emit manually if needed + result._codeflashMutationObserver = observer; + result._codeflashGetMutationCount = () => { + observer.disconnect(); + console.log(`!######DOM_MUTATIONS:${funcName}:${mutationCount}######!`); + return mutationCount; + }; + } + + return Promise.resolve(result); } const runBenchmark = require('./react-benchmark/run'); @@ -1256,6 +1308,22 @@ if (typeof beforeEach !== 'undefined') { }); } +if (typeof afterEach !== 'undefined') { + afterEach(() => { + // Emit DOM mutation markers for any active observers from this test + for (const entry of _activeMutationObservers) { + try { + entry.observer.disconnect(); + const count = entry.getMutationCount(); + console.log(`!######DOM_MUTATIONS:${entry.funcName}:${count}######!`); + } catch (e) { + // Ignore errors from already-disconnected observers + } + } + _activeMutationObservers = []; + }); +} + if (typeof afterAll !== 'undefined') { afterAll(() => { writeResults(); From 9df446b72ff4002e430b18973bdde22a37202f5f Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 16 Mar 2026 22:15:45 +0530 Subject: [PATCH 56/57] update to render duration --- .../frameworks/react/benchmarking.py | 250 ++++++++++-- .../javascript/frameworks/react/profiler.py | 81 ++++ .../javascript/frameworks/react/testgen.py | 60 +++ codeflash/languages/javascript/parse.py | 45 +++ codeflash/models/models.py | 2 + codeflash/optimization/function_optimizer.py | 157 +++++++- codeflash/result/critic.py | 97 ++++- packages/codeflash/runtime/capture.js | 166 ++++---- tests/react/test_benchmarking.py | 361 +++++++++++++++++- tests/react/test_profiler.py | 51 +++ tests/react/test_testgen.py | 35 ++ 11 files changed, 1165 insertions(+), 140 deletions(-) diff --git a/codeflash/languages/javascript/frameworks/react/benchmarking.py b/codeflash/languages/javascript/frameworks/react/benchmarking.py index 6dfa5c764..8afe59d90 100644 --- a/codeflash/languages/javascript/frameworks/react/benchmarking.py +++ b/codeflash/languages/javascript/frameworks/react/benchmarking.py @@ -2,43 +2,124 @@ Compares original vs optimized render profiles from React Profiler instrumentation to quantify re-render reduction and render time improvement. + +Phase-aware: separates mount-phase renders (expected for both original and +optimized) from update-phase renders (the primary signal for optimization +effectiveness — fewer updates means better memoization, debounce, etc.). """ from __future__ import annotations +import logging from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: - from codeflash.languages.javascript.parse import DomMutationProfile, RenderProfile + from codeflash.languages.javascript.parse import DomMutationProfile, InteractionDurationProfile, RenderProfile + +logger = logging.getLogger(__name__) + + +def _group_by_component(profiles: list[RenderProfile]) -> dict[str, list[RenderProfile]]: + """Group render profiles by component name.""" + grouped: dict[str, list[RenderProfile]] = {} + for p in profiles: + grouped.setdefault(p.component_name, []).append(p) + return grouped + + +def _split_by_phase(profiles: list[RenderProfile]) -> tuple[list[RenderProfile], list[RenderProfile]]: + """Split render profiles into mount-phase and update-phase lists.""" + mount: list[RenderProfile] = [] + update: list[RenderProfile] = [] + for p in profiles: + if p.phase == "mount": + mount.append(p) + else: + update.append(p) + return mount, update + + +def _aggregate_render_count(profiles: list[RenderProfile]) -> int: + """Get the max render count from a list of profiles (cumulative counter).""" + return max((p.render_count for p in profiles), default=0) + + +def _aggregate_avg_duration(profiles: list[RenderProfile]) -> float: + """Get the average actual_duration_ms from a list of profiles.""" + if not profiles: + return 0.0 + return sum(p.actual_duration_ms for p in profiles) / len(profiles) @dataclass(frozen=True) class RenderBenchmark: - """Comparison of original vs optimized render metrics.""" + """Comparison of original vs optimized render metrics. + + Provides both total and phase-separated (mount vs update) metrics. + Update-phase render count is the primary signal for optimization value. + """ component_name: str + # Total (all phases combined) original_render_count: int optimized_render_count: int original_avg_duration_ms: float optimized_avg_duration_ms: float original_dom_mutations: int = 0 optimized_dom_mutations: int = 0 + # Update-phase only (primary signal) + original_update_render_count: int = 0 + optimized_update_render_count: int = 0 + original_update_avg_duration_ms: float = 0.0 + optimized_update_avg_duration_ms: float = 0.0 + # Mount-phase only (informational) + original_mount_render_count: int = 0 + optimized_mount_render_count: int = 0 + # Child component render reduction (sum of reductions across all children) + child_render_reduction: int = 0 + # Interaction duration metrics (from MutationObserver timestamps) + original_interaction_duration_ms: float = 0.0 + optimized_interaction_duration_ms: float = 0.0 + original_burst_count: int = 0 + optimized_burst_count: int = 0 @property def render_count_reduction_pct(self) -> float: - """Percentage reduction in render count (0-100).""" + """Percentage reduction in total render count (0-100).""" if self.original_render_count == 0: return 0.0 return (self.original_render_count - self.optimized_render_count) / self.original_render_count * 100 + @property + def update_render_count_reduction_pct(self) -> float: + """Percentage reduction in update-phase render count (0-100).""" + if self.original_update_render_count == 0: + return 0.0 + return ( + (self.original_update_render_count - self.optimized_update_render_count) + / self.original_update_render_count + * 100 + ) + @property def duration_reduction_pct(self) -> float: - """Percentage reduction in render duration (0-100).""" + """Percentage reduction in total render duration (0-100).""" if self.original_avg_duration_ms == 0: return 0.0 return (self.original_avg_duration_ms - self.optimized_avg_duration_ms) / self.original_avg_duration_ms * 100 + @property + def update_duration_reduction_pct(self) -> float: + """Percentage reduction in update-phase render duration (0-100).""" + if self.original_update_avg_duration_ms == 0: + return 0.0 + return ( + (self.original_update_avg_duration_ms - self.optimized_update_avg_duration_ms) + / self.original_update_avg_duration_ms + * 100 + ) + @property def render_speedup_x(self) -> float: """Render time speedup factor (e.g., 2.5x means 2.5 times faster).""" @@ -53,38 +134,124 @@ def dom_mutation_reduction_pct(self) -> float: return 0.0 return (self.original_dom_mutations - self.optimized_dom_mutations) / self.original_dom_mutations * 100 + @property + def has_update_phase_data(self) -> bool: + """Whether update-phase data is available (tests triggered re-renders).""" + return self.original_update_render_count > 0 or self.optimized_update_render_count > 0 + + @property + def interaction_duration_reduction_pct(self) -> float: + """Percentage reduction in interaction-to-settle duration (0-100).""" + if self.original_interaction_duration_ms == 0: + return 0.0 + return ( + (self.original_interaction_duration_ms - self.optimized_interaction_duration_ms) + / self.original_interaction_duration_ms + * 100 + ) + + @property + def has_child_render_data(self) -> bool: + """Whether child component render reduction data is available.""" + return self.child_render_reduction > 0 + + @property + def has_interaction_duration_data(self) -> bool: + """Whether interaction duration data is available.""" + return self.original_interaction_duration_ms > 0 or self.optimized_interaction_duration_ms > 0 + def compare_render_benchmarks( original_profiles: list[RenderProfile], optimized_profiles: list[RenderProfile], original_dom_mutations: list[DomMutationProfile] | None = None, optimized_dom_mutations: list[DomMutationProfile] | None = None, + target_component_name: str | None = None, + original_interaction_durations: list[InteractionDurationProfile] | None = None, + optimized_interaction_durations: list[InteractionDurationProfile] | None = None, ) -> RenderBenchmark | None: - """Compare original and optimized render profiles. + """Compare original and optimized render profiles with phase awareness. - Aggregates render counts and durations across all render events - for the same component, then computes the benchmark comparison. + When target_component_name is provided, uses only the target component's + profiles for the primary comparison and computes child render reductions + from all other components. Falls back to using all profiles when no target + is specified. + + Separates mount-phase and update-phase render profiles. Update-phase + render count is the primary signal for optimization value. """ if not original_profiles or not optimized_profiles: return None - # Use the first profile's component name - component_name = original_profiles[0].component_name + # Group by component for multi-component analysis + orig_by_comp = _group_by_component(original_profiles) + opt_by_comp = _group_by_component(optimized_profiles) + + # Select target component profiles + if target_component_name and target_component_name in orig_by_comp: + component_name = target_component_name + target_orig = orig_by_comp[target_component_name] + target_opt = opt_by_comp.get(target_component_name, []) + else: + component_name = original_profiles[0].component_name + target_orig = original_profiles + target_opt = optimized_profiles + + # Split by phase + orig_mount, orig_update = _split_by_phase(target_orig) + opt_mount, opt_update = _split_by_phase(target_opt) - # Aggregate original metrics - orig_count = max((p.render_count for p in original_profiles), default=0) - orig_durations = [p.actual_duration_ms for p in original_profiles] - orig_avg_duration = sum(orig_durations) / len(orig_durations) if orig_durations else 0.0 + if not orig_update and not opt_update: + logger.debug("No update-phase render markers found — tests may lack interactions") - # Aggregate optimized metrics - opt_count = max((p.render_count for p in optimized_profiles), default=0) - opt_durations = [p.actual_duration_ms for p in optimized_profiles] - opt_avg_duration = sum(opt_durations) / len(opt_durations) if opt_durations else 0.0 + # Aggregate total metrics (all phases) + orig_count = _aggregate_render_count(target_orig) + opt_count = _aggregate_render_count(target_opt) + orig_avg_duration = _aggregate_avg_duration(target_orig) + opt_avg_duration = _aggregate_avg_duration(target_opt) + + # Aggregate update-phase metrics (primary signal) + orig_update_count = _aggregate_render_count(orig_update) + opt_update_count = _aggregate_render_count(opt_update) + orig_update_avg_duration = _aggregate_avg_duration(orig_update) + opt_update_avg_duration = _aggregate_avg_duration(opt_update) + + # Aggregate mount-phase metrics (informational) + orig_mount_count = _aggregate_render_count(orig_mount) + opt_mount_count = _aggregate_render_count(opt_mount) # Aggregate DOM mutation counts orig_dom = sum(p.mutation_count for p in original_dom_mutations) if original_dom_mutations else 0 opt_dom = sum(p.mutation_count for p in optimized_dom_mutations) if optimized_dom_mutations else 0 + # Compute child render reduction (sum of reductions across all non-target children) + child_render_reduction = 0 + if target_component_name: + for comp_name, orig_comp_profiles in orig_by_comp.items(): + if comp_name == target_component_name: + continue + opt_comp_profiles = opt_by_comp.get(comp_name, []) + orig_child_count = _aggregate_render_count(orig_comp_profiles) + opt_child_count = _aggregate_render_count(opt_comp_profiles) + if orig_child_count > opt_child_count: + child_render_reduction += orig_child_count - opt_child_count + + # Aggregate interaction duration metrics + orig_interaction_ms = 0.0 + opt_interaction_ms = 0.0 + orig_bursts = 0 + opt_bursts = 0 + if original_interaction_durations: + orig_interaction_ms = sum(d.duration_ms for d in original_interaction_durations) / len( + original_interaction_durations + ) + orig_bursts = max((d.burst_count for d in original_interaction_durations), default=0) + if optimized_interaction_durations: + opt_interaction_ms = sum(d.duration_ms for d in optimized_interaction_durations) / len( + optimized_interaction_durations + ) + opt_bursts = max((d.burst_count for d in optimized_interaction_durations), default=0) + return RenderBenchmark( component_name=component_name, original_render_count=orig_count, @@ -93,22 +260,51 @@ def compare_render_benchmarks( optimized_avg_duration_ms=opt_avg_duration, original_dom_mutations=orig_dom, optimized_dom_mutations=opt_dom, + original_update_render_count=orig_update_count, + optimized_update_render_count=opt_update_count, + original_update_avg_duration_ms=orig_update_avg_duration, + optimized_update_avg_duration_ms=opt_update_avg_duration, + original_mount_render_count=orig_mount_count, + optimized_mount_render_count=opt_mount_count, + child_render_reduction=child_render_reduction, + original_interaction_duration_ms=orig_interaction_ms, + optimized_interaction_duration_ms=opt_interaction_ms, + original_burst_count=orig_bursts, + optimized_burst_count=opt_bursts, ) def format_render_benchmark_for_pr(benchmark: RenderBenchmark) -> str: - """Format render benchmark data for PR comment body.""" + """Format render benchmark data for PR comment body with mount/update breakdown.""" lines = [ "### React Render Performance", "", "| Metric | Before | After | Improvement |", "|--------|--------|-------|-------------|", - f"| Renders | {benchmark.original_render_count} | {benchmark.optimized_render_count} " - f"| {benchmark.render_count_reduction_pct:.1f}% fewer |", + ] + + if benchmark.has_update_phase_data: + lines.append( + f"| Re-renders (update) | {benchmark.original_update_render_count} " + f"| {benchmark.optimized_update_render_count} " + f"| {benchmark.update_render_count_reduction_pct:.1f}% fewer |" + ) + if benchmark.original_update_avg_duration_ms > 0: + lines.append( + f"| Avg update render time | {benchmark.original_update_avg_duration_ms:.2f}ms " + f"| {benchmark.optimized_update_avg_duration_ms:.2f}ms " + f"| {benchmark.update_duration_reduction_pct:.1f}% faster |" + ) + + lines.append( + f"| Total renders | {benchmark.original_render_count} | {benchmark.optimized_render_count} " + f"| {benchmark.render_count_reduction_pct:.1f}% fewer |" + ) + lines.append( f"| Avg render time | {benchmark.original_avg_duration_ms:.2f}ms " f"| {benchmark.optimized_avg_duration_ms:.2f}ms " - f"| {benchmark.duration_reduction_pct:.1f}% faster |", - ] + f"| {benchmark.duration_reduction_pct:.1f}% faster |" + ) if benchmark.original_dom_mutations > 0 or benchmark.optimized_dom_mutations > 0: lines.append( @@ -116,6 +312,16 @@ def format_render_benchmark_for_pr(benchmark: RenderBenchmark) -> str: f"| {benchmark.dom_mutation_reduction_pct:.1f}% fewer |" ) + if benchmark.has_child_render_data: + lines.append(f"| Child re-renders saved | — | — | {benchmark.child_render_reduction} fewer |") + + if benchmark.has_interaction_duration_data: + lines.append( + f"| Interaction duration | {benchmark.original_interaction_duration_ms:.2f}ms " + f"| {benchmark.optimized_interaction_duration_ms:.2f}ms " + f"| {benchmark.interaction_duration_reduction_pct:.1f}% faster |" + ) + if benchmark.render_speedup_x > 1: lines.append(f"\nRender time improved **{benchmark.render_speedup_x:.1f}x**.") diff --git a/codeflash/languages/javascript/frameworks/react/profiler.py b/codeflash/languages/javascript/frameworks/react/profiler.py index 0723b139f..89bed13ec 100644 --- a/codeflash/languages/javascript/frameworks/react/profiler.py +++ b/codeflash/languages/javascript/frameworks/react/profiler.py @@ -128,6 +128,32 @@ def instrument_all_components_for_tracing(source: str, file_path: Path, analyzer return result +def instrument_all_components_except( + source: str, file_path: Path, analyzer: TreeSitterAnalyzer, exclude_component_name: str +) -> str: + """Instrument all components in a file EXCEPT the specified one. + + Used when captureRenderPerf() handles the target component — child + components still need source-level Profiler instrumentation so that + per-component render data is visible in benchmarks. + """ + from codeflash.languages.javascript.frameworks.react.discovery import find_react_components + + components = find_react_components(source, file_path, analyzer) + if not components: + return source + + result = source + for comp in sorted(components, key=lambda c: c.start_line, reverse=True): + if comp.returns_jsx and comp.function_name != exclude_component_name: + result = instrument_component_with_profiler(result, comp.function_name, analyzer) + + return result + + +_WRAPPER_CALLEES = frozenset({"forwardRef", "memo", "React.forwardRef", "React.memo"}) + + def _find_component_function(root_node: Node, component_name: str, source_bytes: bytes) -> Node | None: """Find the tree-sitter node for a named component function.""" # Check function declarations @@ -144,6 +170,14 @@ def _find_component_function(root_node: Node, component_name: str, source_bytes: if name_node: name = source_bytes[name_node.start_byte : name_node.end_byte].decode("utf-8") if name == component_name: + # If the value is a HOC wrapper (forwardRef/memo), extract the inner function + inner = _unwrap_hoc_call(root_node, source_bytes) + if inner is not None: + return inner + # Return the actual function node so _find_jsx_returns doesn't skip it + value_node = root_node.child_by_field_name("value") + if value_node and value_node.type in ("arrow_function", "function_expression", "function"): + return value_node return root_node # Check export statements @@ -161,6 +195,50 @@ def _find_component_function(root_node: Node, component_name: str, source_bytes: return None +def _unwrap_hoc_call(declarator_node: Node, source_bytes: bytes) -> Node | None: + """Extract the inner function from a HOC wrapper like memo() or forwardRef(). + + Handles patterns like: + const MyComp = React.memo((props) => { ... }) + const MyComp = forwardRef(function MyComp(props, ref) { ... }) + const MyComp = memo(React.forwardRef((props, ref) => { ... })) + """ + value_node = declarator_node.child_by_field_name("value") + if value_node is None or value_node.type != "call_expression": + return None + + callee = value_node.child_by_field_name("function") + if callee is None: + return None + + callee_text = source_bytes[callee.start_byte : callee.end_byte].decode("utf-8") + if callee_text not in _WRAPPER_CALLEES: + return None + + # The first argument to the wrapper is the component function + args_node = value_node.child_by_field_name("arguments") + if args_node is None: + return None + + for child in args_node.children: + if child.type in ("arrow_function", "function_expression", "function"): + return child + # Handle nested wrappers: memo(forwardRef((props, ref) => ...)) + if child.type == "call_expression": + nested_callee = child.child_by_field_name("function") + if nested_callee is not None: + nested_text = source_bytes[nested_callee.start_byte : nested_callee.end_byte].decode("utf-8") + if nested_text in _WRAPPER_CALLEES: + nested_args = child.child_by_field_name("arguments") + if nested_args is not None: + for nested_child in nested_args.children: + if nested_child.type in ("arrow_function", "function_expression", "function"): + return nested_child + + logger.debug("HOC wrapper %s found but could not extract inner function", callee_text) + return None + + def _find_jsx_returns(func_node: Node, source_bytes: bytes) -> list[Node]: """Find all return statements that contain JSX within a function node.""" returns: list[Node] = [] @@ -340,6 +418,9 @@ def _build_render_counter_code(component_name: str, marker_prefix: str) -> str: safe_name = _SAFE_NAME_RE.sub("_", component_name) return f"""\ let _codeflash_render_count_{safe_name} = 0; +if (typeof beforeEach !== 'undefined') {{ + beforeEach(() => {{ _codeflash_render_count_{safe_name} = 0; }}); +}} function _codeflashOnRender_{safe_name}(id, phase, actualDuration, baseDuration) {{ _codeflash_render_count_{safe_name}++; console.log(`!######{marker_prefix}:${{id}}:${{phase}}:${{actualDuration}}:${{baseDuration}}:${{_codeflash_render_count_{safe_name}}}######!`); diff --git a/codeflash/languages/javascript/frameworks/react/testgen.py b/codeflash/languages/javascript/frameworks/react/testgen.py index 199d225a9..d1b406317 100644 --- a/codeflash/languages/javascript/frameworks/react/testgen.py +++ b/codeflash/languages/javascript/frameworks/react/testgen.py @@ -6,9 +6,12 @@ from __future__ import annotations +import logging import re from typing import TYPE_CHECKING, Any +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from codeflash.languages.base import CodeContext from codeflash.languages.javascript.frameworks.react.context import ReactContext @@ -161,4 +164,61 @@ def post_process_react_tests(test_source: str, component_info: ReactComponentInf count=1, ) + # Warn if no tests contain interaction calls — mount-phase only markers are + # not useful for measuring optimization effectiveness. + if not has_react_test_interactions(result): + logger.warning( + "[REACT] Generated tests for %s contain no interactions (fireEvent, userEvent, rerender). " + "Tests will produce only mount-phase markers which cannot measure optimization improvements.", + component_info.function_name, + ) + + # Warn if tests lack high-density interaction patterns (loops or 3+ sequential calls) + if not has_high_density_interactions(result): + logger.warning( + "[REACT] Generated tests for %s lack high-density interactions (no loops with interactions or " + "3+ sequential interaction calls). Render count differences may be too small to measure.", + component_info.function_name, + ) + return result + + +# Patterns that indicate a test triggers user interactions causing re-renders +_INTERACTION_PATTERNS = re.compile( + r"fireEvent\.|userEvent\.|\.rerender\(|rerender\(|act\(" +) + + +def has_react_test_interactions(test_source: str) -> bool: + """Check if a React test contains interactions that trigger re-renders. + + Returns True if the test source contains any of: fireEvent, userEvent, + rerender(), or act() calls — all of which cause update-phase renders + that the Profiler can measure. + """ + return bool(_INTERACTION_PATTERNS.search(test_source)) + + +# Patterns for loops containing interaction calls +_LOOP_WITH_INTERACTION = re.compile( + r"for\s*\([^)]*\)\s*\{[^}]*(?:fireEvent\.|userEvent\.|rerender\()", + re.DOTALL, +) + +# Minimum sequential interaction calls to consider "high density" +_MIN_SEQUENTIAL_INTERACTIONS = 3 + + +def has_high_density_interactions(test_source: str) -> bool: + """Check if tests contain high-density interaction patterns. + + Returns True if the test source contains either: + - A loop (for/while) with interaction calls inside, OR + - 3+ sequential interaction calls (fireEvent/userEvent/rerender) + """ + if _LOOP_WITH_INTERACTION.search(test_source): + return True + + interaction_calls = _INTERACTION_PATTERNS.findall(test_source) + return len(interaction_calls) >= _MIN_SEQUENTIAL_INTERACTIONS diff --git a/codeflash/languages/javascript/parse.py b/codeflash/languages/javascript/parse.py index 8020ed85b..960d8dc77 100644 --- a/codeflash/languages/javascript/parse.py +++ b/codeflash/languages/javascript/parse.py @@ -41,6 +41,10 @@ # Format: !######DOM_MUTATIONS:{component}:{mutationCount}######! DOM_MUTATION_MARKER_PATTERN = re.compile(r"!######DOM_MUTATIONS:([^:]+):(\d+)######!") +# React interaction duration marker pattern +# Format: !######REACT_INTERACTION_DURATION:{component}:{durationMs}:{burstCount}######! +REACT_INTERACTION_DURATION_PATTERN = re.compile(r"!######REACT_INTERACTION_DURATION:([^:]+):([^:]+):(\d+)######!") + @dataclass(frozen=True) class RenderProfile: @@ -59,7 +63,9 @@ def parse_react_render_markers(stdout: str) -> list[RenderProfile]: Returns a list of RenderProfile instances, one per marker found. """ profiles: list[RenderProfile] = [] + total_matches = 0 for match in REACT_RENDER_MARKER_PATTERN.finditer(stdout): + total_matches += 1 try: profiles.append( RenderProfile( @@ -72,6 +78,10 @@ def parse_react_render_markers(stdout: str) -> list[RenderProfile]: ) except (ValueError, IndexError) as e: logger.debug("Failed to parse React render marker: %s", e) + if total_matches > 0 and not profiles: + logger.warning( + "[REACT] All %d REACT_RENDER markers were malformed — marker format may have changed", total_matches + ) return profiles @@ -89,7 +99,9 @@ def parse_dom_mutation_markers(stdout: str) -> list[DomMutationProfile]: Returns a list of DomMutationProfile instances, one per marker found. """ profiles: list[DomMutationProfile] = [] + total_matches = 0 for match in DOM_MUTATION_MARKER_PATTERN.finditer(stdout): + total_matches += 1 try: profiles.append( DomMutationProfile( @@ -99,6 +111,39 @@ def parse_dom_mutation_markers(stdout: str) -> list[DomMutationProfile]: ) except (ValueError, IndexError) as e: logger.debug("Failed to parse DOM mutation marker: %s", e) + if total_matches > 0 and not profiles: + logger.warning( + "[REACT] All %d DOM_MUTATIONS markers were malformed — marker format may have changed", total_matches + ) + return profiles + + +@dataclass(frozen=True) +class InteractionDurationProfile: + """Parsed interaction-to-settle duration from MutationObserver timestamps.""" + + component_name: str + duration_ms: float + burst_count: int + + +def parse_interaction_duration_markers(stdout: str) -> list[InteractionDurationProfile]: + """Parse interaction duration markers from test output. + + Returns a list of InteractionDurationProfile instances, one per marker found. + """ + profiles: list[InteractionDurationProfile] = [] + for match in REACT_INTERACTION_DURATION_PATTERN.finditer(stdout): + try: + profiles.append( + InteractionDurationProfile( + component_name=match.group(1), + duration_ms=float(match.group(2)), + burst_count=int(match.group(3)), + ) + ) + except (ValueError, IndexError) as e: + logger.debug("Failed to parse interaction duration marker: %s", e) return profiles diff --git a/codeflash/models/models.py b/codeflash/models/models.py index 2f3ef0598..7798437c3 100644 --- a/codeflash/models/models.py +++ b/codeflash/models/models.py @@ -403,6 +403,7 @@ class OptimizedCandidateResult(BaseModel): concurrency_metrics: Optional[ConcurrencyMetrics] = None render_profiles: Optional[list[Any]] = None dom_mutations: Optional[list[Any]] = None + interaction_durations: Optional[list[Any]] = None class GeneratedTests(BaseModel): @@ -638,6 +639,7 @@ class OriginalCodeBaseline(BaseModel): concurrency_metrics: Optional[ConcurrencyMetrics] = None render_profiles: Optional[list[Any]] = None dom_mutations: Optional[list[Any]] = None + interaction_durations: Optional[list[Any]] = None class CoverageStatus(Enum): diff --git a/codeflash/optimization/function_optimizer.py b/codeflash/optimization/function_optimizer.py index f5c0c2581..2d305e968 100644 --- a/codeflash/optimization/function_optimizer.py +++ b/codeflash/optimization/function_optimizer.py @@ -501,29 +501,68 @@ def is_react_component(self) -> bool: metadata = self.function_to_optimize.metadata or {} return bool(metadata.get("is_react_component", False)) + def tests_use_capture_render_perf(self) -> bool: + """Check if generated perf test files use captureRenderPerf(). + + When tests use captureRenderPerf, the runtime wrapper in capture.js + handles React.Profiler instrumentation — no source-level rewriting needed. + """ + for tf in self.test_files.test_files: + if tf.benchmarking_file_path and tf.benchmarking_file_path.exists(): + try: + content = tf.benchmarking_file_path.read_text("utf-8") + if "captureRenderPerf" in content: + return True + except OSError: + continue + return False + def instrument_source_with_react_profiler(self) -> str | None: - """Instrument the source file with React.Profiler if this is a React component. + """Instrument the source file with React.Profiler for render measurement. + + When tests use captureRenderPerf(), the target component is already + wrapped by the runtime — only child components need source-level + Profiler instrumentation so per-component render data is visible. + + When captureRenderPerf() is NOT used, wraps ALL components. Returns the original source code (for restoration) if instrumentation succeeded, None otherwise. """ if not self.is_react_component: return None try: - from codeflash.languages.javascript.frameworks.react.profiler import instrument_component_with_profiler - from codeflash.languages.javascript.treesitter import get_analyzer_for_file + from codeflash.languages.javascript.frameworks.react.profiler import ( # noqa: PLC0415 + instrument_all_components_except, + instrument_all_components_for_tracing, + ) + from codeflash.languages.javascript.treesitter import get_analyzer_for_file # noqa: PLC0415 file_path = self.function_to_optimize.file_path original_source = file_path.read_text("utf-8") analyzer = get_analyzer_for_file(file_path) - instrumented = instrument_component_with_profiler( - original_source, self.function_to_optimize.function_name, analyzer - ) + + if self.tests_use_capture_render_perf(): + target_name = self.function_to_optimize.function_name + instrumented = instrument_all_components_except(original_source, file_path, analyzer, target_name) + label = f"child components (excluding {target_name})" + else: + instrumented = instrument_all_components_for_tracing(original_source, file_path, analyzer) + label = "all components" + if instrumented != original_source: file_path.write_text(instrumented, encoding="utf-8") - logger.debug(f"Instrumented {self.function_to_optimize.function_name} with React.Profiler") + logger.debug(f"Instrumented {label} in {file_path.name} with React.Profiler") return original_source + logger.warning( + "[REACT] Profiler instrumentation did not modify source for %s — render markers will not be emitted", + self.function_to_optimize.function_name, + ) except Exception: - logger.debug("Failed to instrument source with React.Profiler", exc_info=True) + logger.warning( + "[REACT] Failed to instrument source with React.Profiler for %s", + self.function_to_optimize.function_name, + exc_info=True, + ) return None def restore_source_after_profiler(self, original_source: str | None) -> None: @@ -542,6 +581,10 @@ def parse_render_profiles_from_results(self, test_results: TestResults) -> list if profiles: logger.debug(f"Parsed {len(profiles)} React render profiles from test output") return profiles + logger.warning( + "[REACT] No REACT_RENDER markers found in perf output despite profiler mode being active" + ) + logger.debug("[REACT] perf_stdout preview (first 500 chars): %s", test_results.perf_stdout[:500]) except Exception: logger.debug("Failed to parse React render markers", exc_info=True) return None @@ -551,16 +594,32 @@ def parse_dom_mutations_from_results(self, test_results: TestResults) -> list | if not self.is_react_component or not test_results.perf_stdout: return None try: - from codeflash.languages.javascript.parse import parse_dom_mutation_markers + from codeflash.languages.javascript.parse import parse_dom_mutation_markers # noqa: PLC0415 profiles = parse_dom_mutation_markers(test_results.perf_stdout) if profiles: logger.debug(f"Parsed {len(profiles)} DOM mutation profiles from test output") return profiles + logger.warning("[REACT] No DOM_MUTATIONS markers found in perf output despite profiler mode being active") except Exception: logger.debug("Failed to parse DOM mutation markers", exc_info=True) return None + def parse_interaction_durations_from_results(self, test_results: TestResults) -> list | None: + """Parse interaction duration markers from test stdout.""" + if not self.is_react_component or not test_results.perf_stdout: + return None + try: + from codeflash.languages.javascript.parse import parse_interaction_duration_markers # noqa: PLC0415 + + profiles = parse_interaction_duration_markers(test_results.perf_stdout) + if profiles: + logger.debug(f"Parsed {len(profiles)} interaction duration profiles from test output") + return profiles + except Exception: + logger.debug("Failed to parse interaction duration markers", exc_info=True) + return None + def can_be_optimized(self) -> Result[tuple[bool, CodeOptimizationContext, dict[Path, str]], str]: should_run_experiment = self.experiment_id is not None logger.info(f"!lsp|Function Trace ID: {self.function_trace_id}") @@ -953,13 +1012,16 @@ def handle_successful_candidate( # Compute React render benchmark if profiler data is available render_benchmark = None if original_code_baseline.render_profiles and candidate_result.render_profiles: - from codeflash.languages.javascript.frameworks.react.benchmarking import compare_render_benchmarks + from codeflash.languages.javascript.frameworks.react.benchmarking import compare_render_benchmarks # noqa: PLC0415 render_benchmark = compare_render_benchmarks( original_code_baseline.render_profiles, candidate_result.render_profiles, original_dom_mutations=original_code_baseline.dom_mutations, optimized_dom_mutations=candidate_result.dom_mutations, + target_component_name=self.function_to_optimize.function_name, + original_interaction_durations=original_code_baseline.interaction_durations, + optimized_interaction_durations=candidate_result.interaction_durations, ) best_optimization = BestOptimization( @@ -1853,6 +1915,65 @@ def generate_tests( ) ) + # For React components, validate that tests have interactions (fireEvent, userEvent, etc.) + # Tests without interactions only produce mount-phase markers, which aren't useful. + if self.is_react_component and tests: + from codeflash.languages.javascript.frameworks.react.testgen import has_react_test_interactions # noqa: PLC0415 + + tests_without_interactions = [ + (i, t) + for i, t in enumerate(tests) + if not has_react_test_interactions(t.generated_original_test_source) + ] + if tests_without_interactions: + logger.debug( + f"[REACT-TESTGEN] {len(tests_without_interactions)}/{len(tests)} tests lack interactions, retrying" + ) + max_retries = 2 + for retry in range(max_retries): + retry_futures = [ + self.executor.submit( + generate_tests, + self.aiservice_client, + testgen_context.markdown, + self.function_to_optimize, + testgen_helper_fqns + or [d.fully_qualified_name for d in helper_functions], + Path(self.original_module_path), + self.test_cfg, + INDIVIDUAL_TESTCASE_TIMEOUT, + self.function_trace_id, + idx, + tests[idx].behavior_file_path, + tests[idx].perf_file_path, + self.is_numerical_code, + self.is_react_component, + (self.function_to_optimize.metadata or {}).get("react_context"), + ) + for idx, _t in tests_without_interactions + ] + concurrent.futures.wait(retry_futures) + still_missing = [] + for (idx, _t), future in zip(tests_without_interactions, retry_futures): + res = future.result() + if res and has_react_test_interactions(res[0]): + tests[idx] = GeneratedTests( + generated_original_test_source=res[0], + instrumented_behavior_test_source=res[1], + instrumented_perf_test_source=res[2], + behavior_file_path=res[3], + perf_file_path=res[4], + ) + else: + still_missing.append((idx, tests[idx])) + tests_without_interactions = still_missing + if not tests_without_interactions: + break + if tests_without_interactions: + logger.warning( + f"[REACT-TESTGEN] {len(tests_without_interactions)} tests still lack interactions after retries" + ) + if not tests: logger.warning(f"Failed to generate and instrument tests for {self.function_to_optimize.function_name}") return Failure(f"/!\\ NO TESTS GENERATED for {self.function_to_optimize.function_name}") @@ -2076,6 +2197,10 @@ def find_and_process_best_optimization( optimized_render_duration=rb.optimized_avg_duration_ms if rb else None, original_dom_mutations=rb.original_dom_mutations if rb else 0, optimized_dom_mutations=rb.optimized_dom_mutations if rb else 0, + original_update_render_count=rb.original_update_render_count if rb else 0, + optimized_update_render_count=rb.optimized_update_render_count if rb else 0, + original_update_duration=rb.original_update_avg_duration_ms if rb else 0.0, + optimized_update_duration=rb.optimized_update_avg_duration_ms if rb else 0.0, ) # Format render benchmark markdown for PR/explanation @@ -2481,7 +2606,7 @@ def establish_original_code_baseline( # Instrument source with React.Profiler for render measurement pre_profiler_source = self.instrument_source_with_react_profiler() - if pre_profiler_source is not None: + if self.is_react_component: test_env["CODEFLASH_REACT_PROFILER_MODE"] = "true" try: @@ -2504,9 +2629,10 @@ def establish_original_code_baseline( self.function_to_optimize_source_code, original_helper_code, self.function_to_optimize.file_path ) - # Parse React render profiles and DOM mutation data from performance test stdout + # Parse React render profiles, DOM mutations, and interaction durations from performance test stdout original_render_profiles = self.parse_render_profiles_from_results(benchmarking_results) original_dom_mutations = self.parse_dom_mutations_from_results(benchmarking_results) + original_interaction_durations = self.parse_interaction_durations_from_results(benchmarking_results) console.print( TestResults.report_to_tree( @@ -2575,6 +2701,7 @@ def establish_original_code_baseline( concurrency_metrics=concurrency_metrics, render_profiles=original_render_profiles, dom_mutations=original_dom_mutations, + interaction_durations=original_interaction_durations, ), functions_to_remove, ) @@ -2752,7 +2879,7 @@ def run_optimized_candidate( # Instrument candidate source with React.Profiler for render measurement pre_profiler_source = self.instrument_source_with_react_profiler() - if pre_profiler_source is not None: + if self.is_react_component: test_env["CODEFLASH_REACT_PROFILER_MODE"] = "true" try: @@ -2774,9 +2901,10 @@ def run_optimized_candidate( candidate_fto_code, candidate_helper_code, self.function_to_optimize.file_path ) - # Parse React render profiles and DOM mutation data from candidate performance test stdout + # Parse React render profiles, DOM mutations, and interaction durations from candidate performance test stdout candidate_render_profiles = self.parse_render_profiles_from_results(candidate_benchmarking_results) candidate_dom_mutations = self.parse_dom_mutations_from_results(candidate_benchmarking_results) + candidate_interaction_durations = self.parse_interaction_durations_from_results(candidate_benchmarking_results) # Use effective_loop_count which represents the minimum number of timing samples # across all test cases. This is more accurate for JavaScript tests where # capturePerf does internal looping with potentially different iteration counts per test. @@ -2829,6 +2957,7 @@ def run_optimized_candidate( concurrency_metrics=candidate_concurrency_metrics, render_profiles=candidate_render_profiles, dom_mutations=candidate_dom_mutations, + interaction_durations=candidate_interaction_durations, ) ) diff --git a/codeflash/result/critic.py b/codeflash/result/critic.py index f6b9b2b13..4cbe5887f 100644 --- a/codeflash/result/critic.py +++ b/codeflash/result/critic.py @@ -112,7 +112,7 @@ def speedup_critic( # React render efficiency evaluation render_efficiency_improved = False if original_render_profiles and candidate_result.render_profiles: - from codeflash.languages.javascript.frameworks.react.benchmarking import compare_render_benchmarks + from codeflash.languages.javascript.frameworks.react.benchmarking import compare_render_benchmarks # noqa: PLC0415 benchmark = compare_render_benchmarks(original_render_profiles, candidate_result.render_profiles) if benchmark: @@ -123,6 +123,14 @@ def speedup_critic( benchmark.optimized_avg_duration_ms, original_dom_mutations=benchmark.original_dom_mutations, optimized_dom_mutations=benchmark.optimized_dom_mutations, + original_update_render_count=benchmark.original_update_render_count, + optimized_update_render_count=benchmark.optimized_update_render_count, + original_update_duration=benchmark.original_update_avg_duration_ms, + optimized_update_duration=benchmark.optimized_update_avg_duration_ms, + child_render_reduction=benchmark.child_render_reduction, + original_interaction_duration_ms=benchmark.original_interaction_duration_ms, + optimized_interaction_duration_ms=benchmark.optimized_interaction_duration_ms, + trust_duration=False, ) throughput_improved = True # Default to True if no throughput data @@ -176,6 +184,13 @@ def get_acceptance_reason( optimized_render_duration: float | None = None, original_dom_mutations: int = 0, optimized_dom_mutations: int = 0, + original_update_render_count: int = 0, + optimized_update_render_count: int = 0, + original_update_duration: float = 0.0, + optimized_update_duration: float = 0.0, + child_render_reduction: int = 0, + original_interaction_duration_ms: float = 0.0, + optimized_interaction_duration_ms: float = 0.0, ) -> AcceptanceReason: """Determine why an optimization was accepted. @@ -204,6 +219,14 @@ def get_acceptance_reason( optimized_render_duration, original_dom_mutations=original_dom_mutations, optimized_dom_mutations=optimized_dom_mutations, + original_update_render_count=original_update_render_count, + optimized_update_render_count=optimized_update_render_count, + original_update_duration=original_update_duration, + optimized_update_duration=optimized_update_duration, + child_render_reduction=child_render_reduction, + original_interaction_duration_ms=original_interaction_duration_ms, + optimized_interaction_duration_ms=optimized_interaction_duration_ms, + trust_duration=False, ) throughput_improved = False @@ -268,6 +291,11 @@ def coverage_critic(original_code_coverage: CoverageData | None) -> bool: MIN_DOM_MUTATION_REDUCTION_PCT = 0.20 # 20% +MIN_INTERACTION_DURATION_REDUCTION_PCT = 0.20 # 20% + +MIN_CHILD_RENDER_REDUCTION = 2 + + def render_efficiency_critic( original_render_count: int, optimized_render_count: int, @@ -276,29 +304,55 @@ def render_efficiency_critic( best_render_count_until_now: int | None = None, original_dom_mutations: int = 0, optimized_dom_mutations: int = 0, + original_update_render_count: int = 0, + optimized_update_render_count: int = 0, + original_update_duration: float = 0.0, + optimized_update_duration: float = 0.0, + child_render_reduction: int = 0, + original_interaction_duration_ms: float = 0.0, + optimized_interaction_duration_ms: float = 0.0, + trust_duration: bool = True, ) -> bool: """Evaluate whether a React optimization reduces re-renders, render time, or DOM mutations sufficiently. + Uses update-phase render counts as primary signal when available (tests that + trigger interactions produce update-phase markers). Falls back to total + render count if no update-phase data exists. + + When ``trust_duration`` is False (e.g. jsdom where actualDuration is noise), + render duration is excluded from the acceptance criteria. + Accepts if: - - Render count is reduced by >= 20% - - OR render duration is reduced by >= MIN_IMPROVEMENT_THRESHOLD - - OR DOM mutations are reduced by >= 20% + - Update render count reduced by >= 20% (primary), OR total render count reduced by >= 20% (fallback) + - OR render duration reduced by >= MIN_IMPROVEMENT_THRESHOLD (when trust_duration=True) + - OR DOM mutations reduced by >= 20% + - OR child component render reduction >= MIN_CHILD_RENDER_REDUCTION (captures useCallback/memo optimizations) + - OR interaction duration reduced by >= 20% (captures debounce/throttle optimizations) - AND the candidate is the best seen so far """ - if original_render_count == 0 and original_dom_mutations == 0: + if original_render_count == 0 and original_dom_mutations == 0 and child_render_reduction == 0: return False + # Use update-phase counts as primary signal when available + has_update_data = original_update_render_count > 0 or optimized_update_render_count > 0 + effective_orig_count = original_update_render_count if has_update_data else original_render_count + effective_opt_count = optimized_update_render_count if has_update_data else optimized_render_count + # Check render count reduction count_improved = False - if original_render_count > 0: - count_reduction = (original_render_count - optimized_render_count) / original_render_count + if effective_orig_count > 0: + count_reduction = (effective_orig_count - effective_opt_count) / effective_orig_count count_improved = count_reduction >= MIN_RENDER_COUNT_REDUCTION_PCT - # Check render duration reduction + # Check render duration reduction (prefer update-phase duration) + # Skipped when trust_duration=False (jsdom actualDuration is noise) duration_improved = False - if original_render_duration > 0: - duration_gain = (original_render_duration - optimized_render_duration) / original_render_duration - duration_improved = duration_gain > MIN_IMPROVEMENT_THRESHOLD + if trust_duration: + effective_orig_duration = original_update_duration if has_update_data else original_render_duration + effective_opt_duration = optimized_update_duration if has_update_data else optimized_render_duration + if effective_orig_duration > 0: + duration_gain = (effective_orig_duration - effective_opt_duration) / effective_orig_duration + duration_improved = duration_gain > MIN_IMPROVEMENT_THRESHOLD # Check DOM mutation reduction dom_mutations_improved = False @@ -306,7 +360,24 @@ def render_efficiency_critic( dom_reduction = (original_dom_mutations - optimized_dom_mutations) / original_dom_mutations dom_mutations_improved = dom_reduction >= MIN_DOM_MUTATION_REDUCTION_PCT + # Check child render reduction (useCallback/memo optimization signal) + child_renders_improved = child_render_reduction >= MIN_CHILD_RENDER_REDUCTION + + # Check interaction duration reduction (debounce/throttle optimization signal) + interaction_duration_improved = False + if original_interaction_duration_ms > 0: + interaction_reduction = ( + (original_interaction_duration_ms - optimized_interaction_duration_ms) / original_interaction_duration_ms + ) + interaction_duration_improved = interaction_reduction >= MIN_INTERACTION_DURATION_REDUCTION_PCT + # Check if this is the best candidate so far - is_best = best_render_count_until_now is None or optimized_render_count <= best_render_count_until_now + is_best = best_render_count_until_now is None or effective_opt_count <= best_render_count_until_now - return (count_improved or duration_improved or dom_mutations_improved) and is_best + return ( + count_improved + or duration_improved + or dom_mutations_improved + or child_renders_improved + or interaction_duration_improved + ) and is_best diff --git a/packages/codeflash/runtime/capture.js b/packages/codeflash/runtime/capture.js index 5100650f0..2a9054881 100644 --- a/packages/codeflash/runtime/capture.js +++ b/packages/codeflash/runtime/capture.js @@ -669,21 +669,29 @@ function capture(funcName, lineId, fn, ...args) { */ function capturePerf(funcName, lineId, fn, ...args) { // In React Profiler mode, skip the benchmarking loop. Just call the function - // once and let React.Profiler instrumentation in the source capture render metrics. + // once. Render metrics are captured by captureRenderPerf()'s React.Profiler + // wrapper or by source-level instrumentation (legacy fallback). if (process.env.CODEFLASH_REACT_PROFILER_MODE === 'true') { const result = fn(...args); - // Set up DOM mutation counting for React profiler mode + // Set up DOM mutation counting and timestamp tracking for React profiler mode const MutationObserverCls = globalThis.MutationObserver; if (MutationObserverCls && result && result.container) { let mutationCount = 0; + const mutationTimestamps = []; const observer = new MutationObserverCls((mutations) => { mutationCount += mutations.length; + mutationTimestamps.push(performance.now()); }); observer.observe(result.container, { childList: true, subtree: true, attributes: true }); - const entry = { funcName, observer, getMutationCount: () => mutationCount }; + const entry = { + funcName, + observer, + getMutationCount: () => mutationCount, + getMutationTimestamps: () => mutationTimestamps, + }; _activeMutationObservers.push(entry); } @@ -1086,107 +1094,68 @@ function captureRender(funcName, lineId, renderFn, Component, ...createElementAr /** * Capture a React component render call for PERFORMANCE benchmarking only. * - * This is the render-specific counterpart to capturePerf(). It measures the - * time spent in render() calls with the same batched looping, time budget, - * and stability checking as capturePerf. - * - * Between loop iterations the previous render result is unmounted to keep - * the DOM clean and ensure each iteration starts from the same state. + * Wraps the component in React.Profiler at the render call site to emit + * REACT_RENDER markers on every mount and update phase — no source-level + * instrumentation needed. Sets up a MutationObserver to count DOM changes + * during subsequent test interactions (fireEvent, rerender). * - * When CODEFLASH_REACT_PROFILER_MODE is enabled, skips the Benchmark.jsx - * mount/unmount cycling and renders once normally. The React.Profiler - * instrumentation in the source code emits timing data automatically via - * stdout markers, so no additional benchmarking is needed. + * The test continues normally after this call — interactions trigger re-renders + * which produce update-phase Profiler markers (the primary optimization signal). * * @param {string} funcName - Name of the component being tested (static) * @param {string} lineId - Line number identifier in test file (static) * @param {Function} renderFn - The render function from @testing-library/react * @param {Function|object} Component - The React component to render * @param {...any} createElementArgs - Arguments for React.createElement (props, children) - * @returns {object} - The render result from the final iteration + * @returns {object} - The render result (wrapped in Promise for API compat) * @throws {Error} - Re-throws any error from rendering */ function captureRenderPerf(funcName, lineId, renderFn, Component, ...createElementArgs) { - // In Profiler mode, skip Benchmark.jsx cycling. The React.Profiler wrapper - // in the source code emits render markers automatically on every render. - // Just render once normally so test assertions pass. - if (process.env.CODEFLASH_REACT_PROFILER_MODE === 'true') { - const React = _getReact(); - const element = React.createElement(Component, ...createElementArgs); - - const result = renderFn(element); - - // Set up DOM mutation counting after initial render - const MutationObserverCls = globalThis.MutationObserver; - if (MutationObserverCls && result && result.container) { - let mutationCount = 0; - const observer = new MutationObserverCls((mutations) => { - mutationCount += mutations.length; - }); - observer.observe(result.container, { - childList: true, subtree: true, attributes: true - }); - - // Track for automatic emission in afterEach - const entry = { funcName, observer, getMutationCount: () => mutationCount }; - _activeMutationObservers.push(entry); - - // Attach cleanup helper so tests can also emit manually if needed - result._codeflashMutationObserver = observer; - result._codeflashGetMutationCount = () => { - observer.disconnect(); - console.log(`!######DOM_MUTATIONS:${funcName}:${mutationCount}######!`); - return mutationCount; - }; - } + const React = _getReact(); - return Promise.resolve(result); + let renderCount = 0; + function onRender(id, phase, actualDuration, baseDuration) { + renderCount++; + console.log(`!######REACT_RENDER:${funcName}:${phase}:${actualDuration}:${baseDuration}:${renderCount}######!`); } - const runBenchmark = require('./react-benchmark/run'); - - const { testClassName, safeModulePath, safeTestFunctionName } = _getTestContext(); - - const invocationKey = `${safeModulePath}:${testClassName}:${safeTestFunctionName}:${funcName}:${lineId}`; - - const numSamples = getPerfLoopCount() > 1 ? getPerfLoopCount() : 50; - - // createElementArgs matches React.createElement signature: (props, ...children) - const props = createElementArgs[0] || {}; - - const MS_TO_NS = 1e6; - - return runBenchmark({ - component: Component, - props, - samples: numSamples, - type: 'mount', - }).then((results) => { - // Emit perf markers for each sample so the Python parser can collect timings - for (let i = 0; i < results.samples.length; i++) { - const sample = results.samples[i]; - const durationNs = Math.round(sample.elapsed * MS_TO_NS); - - const loopIndex = getInvocationLoopIndex(invocationKey); - const testId = `${safeModulePath}:${testClassName}:${safeTestFunctionName}:${lineId}:${loopIndex}`; - const invocationIndex = getInvocationIndex(testId); - const invocationId = `${lineId}_${invocationIndex}`; - const testStdoutTag = `${safeModulePath}:${testClassName ? testClassName + '.' : ''}${safeTestFunctionName}:${funcName}:${loopIndex}:${invocationId}`; + const element = React.createElement(Component, ...createElementArgs); + const wrapped = React.createElement(React.Profiler, { id: funcName, onRender }, element); + + const result = renderFn(wrapped); + + // Set up DOM mutation counting and timestamp tracking after initial render + const MutationObserverCls = globalThis.MutationObserver; + if (MutationObserverCls && result && result.container) { + let mutationCount = 0; + const mutationTimestamps = []; + const observer = new MutationObserverCls((mutations) => { + mutationCount += mutations.length; + mutationTimestamps.push(performance.now()); + }); + observer.observe(result.container, { + childList: true, subtree: true, attributes: true + }); - console.log(`!######${testStdoutTag}:${durationNs}######!`); - sharedPerfState.totalLoopsCompleted++; - } + // Track for automatic emission in afterEach + const entry = { + funcName, + observer, + getMutationCount: () => mutationCount, + getMutationTimestamps: () => mutationTimestamps, + }; + _activeMutationObservers.push(entry); + + // Attach cleanup helper so tests can also emit manually if needed + result._codeflashMutationObserver = observer; + result._codeflashGetMutationCount = () => { + observer.disconnect(); + console.log(`!######DOM_MUTATIONS:${funcName}:${mutationCount}######!`); + return mutationCount; + }; + } - // Render once more so the test's own assertions (e.g. screen.getByText) still pass - const React = _getReact(); - const element = React.createElement(Component, ...createElementArgs); - return renderFn(element); - }).catch(() => { - // If benchmark fails, render once so test assertions can still run - const React = _getReact(); - const element = React.createElement(Component, ...createElementArgs); - return renderFn(element); - }); + return Promise.resolve(result); } /** @@ -1308,14 +1277,31 @@ if (typeof beforeEach !== 'undefined') { }); } +function _countBursts(timestamps, gapMs) { + if (timestamps.length === 0) return 0; + let bursts = 1; + for (let i = 1; i < timestamps.length; i++) { + if (timestamps[i] - timestamps[i - 1] > gapMs) bursts++; + } + return bursts; +} + if (typeof afterEach !== 'undefined') { afterEach(() => { - // Emit DOM mutation markers for any active observers from this test + // Emit DOM mutation and interaction duration markers for any active observers from this test for (const entry of _activeMutationObservers) { try { entry.observer.disconnect(); const count = entry.getMutationCount(); console.log(`!######DOM_MUTATIONS:${entry.funcName}:${count}######!`); + + // Emit interaction-to-settle duration marker + const timestamps = entry.getMutationTimestamps ? entry.getMutationTimestamps() : []; + if (timestamps.length > 1) { + const durationMs = timestamps[timestamps.length - 1] - timestamps[0]; + const burstCount = _countBursts(timestamps, 10); + console.log(`!######REACT_INTERACTION_DURATION:${entry.funcName}:${durationMs.toFixed(2)}:${burstCount}######!`); + } } catch (e) { // Ignore errors from already-disconnected observers } diff --git a/tests/react/test_benchmarking.py b/tests/react/test_benchmarking.py index 1828f53e8..e997427fc 100644 --- a/tests/react/test_benchmarking.py +++ b/tests/react/test_benchmarking.py @@ -7,7 +7,8 @@ compare_render_benchmarks, format_render_benchmark_for_pr, ) -from codeflash.languages.javascript.parse import RenderProfile +from codeflash.languages.javascript.frameworks.react.testgen import has_high_density_interactions, has_react_test_interactions +from codeflash.languages.javascript.parse import InteractionDurationProfile, RenderProfile from codeflash.result.critic import render_efficiency_critic @@ -80,6 +81,21 @@ def test_basic_comparison(self): assert benchmark.component_name == "Counter" assert benchmark.original_render_count == 10 # max of [1, 5, 10] assert benchmark.optimized_render_count == 2 # max of [1, 2] + # Phase-aware fields + assert benchmark.original_update_render_count == 10 # max of update profiles [5, 10] + assert benchmark.optimized_update_render_count == 2 # max of update profiles [2] + assert benchmark.original_mount_render_count == 1 + assert benchmark.optimized_mount_render_count == 1 + assert benchmark.has_update_phase_data is True + + def test_mount_only_comparison(self): + original = [RenderProfile("Widget", "mount", 5.0, 8.0, 1)] + optimized = [RenderProfile("Widget", "mount", 4.0, 7.0, 1)] + benchmark = compare_render_benchmarks(original, optimized) + assert benchmark is not None + assert benchmark.original_update_render_count == 0 + assert benchmark.optimized_update_render_count == 0 + assert benchmark.has_update_phase_data is False def test_empty_profiles(self): assert compare_render_benchmarks([], []) is None @@ -112,6 +128,7 @@ def test_markdown_table(self): ) output = format_render_benchmark_for_pr(b) assert "React Render Performance" in output + assert "Total renders" in output assert "47" in output assert "3" in output assert "93.6%" in output @@ -171,3 +188,345 @@ def test_accepts_better_than_best(self): optimized_render_duration=10.0, best_render_count_until_now=5, ) is True + + def test_uses_update_phase_counts_when_available(self): + # Total counts look similar, but update-phase shows big reduction + assert render_efficiency_critic( + original_render_count=10, + optimized_render_count=9, + original_render_duration=100.0, + optimized_render_duration=95.0, + original_update_render_count=8, + optimized_update_render_count=2, + original_update_duration=80.0, + optimized_update_duration=10.0, + ) is True + + def test_falls_back_to_total_when_no_update_data(self): + # No update-phase data → uses total counts + assert render_efficiency_critic( + original_render_count=50, + optimized_render_count=10, + original_render_duration=100.0, + optimized_render_duration=100.0, + original_update_render_count=0, + optimized_update_render_count=0, + ) is True + + +class TestFormatWithPhaseData: + def test_shows_update_phase_row(self): + b = RenderBenchmark( + component_name="Counter", + original_render_count=12, + optimized_render_count=4, + original_avg_duration_ms=10.0, + optimized_avg_duration_ms=3.0, + original_update_render_count=10, + optimized_update_render_count=2, + original_update_avg_duration_ms=8.0, + optimized_update_avg_duration_ms=1.5, + original_mount_render_count=2, + optimized_mount_render_count=2, + ) + output = format_render_benchmark_for_pr(b) + assert "Re-renders (update)" in output + assert "Total renders" in output + + +class TestPerTestRenderCounting: + def test_per_test_reset_with_max_aggregation(self): + """With per-test counter reset, each test's markers start from 0. + + Test A: 3 renders → markers with counts 1,2,3 (max=3) + Test B: 7 renders → markers with counts 1,2,...,7 (max=7) + max() across all markers = 7 (worst single test), not 10 (cumulative). + """ + # Simulate markers from two tests with per-test reset + test_a_profiles = [ + RenderProfile("Counter", "update", 2.0, 5.0, 1), + RenderProfile("Counter", "update", 2.0, 5.0, 2), + RenderProfile("Counter", "update", 2.0, 5.0, 3), + ] + test_b_profiles = [ + RenderProfile("Counter", "update", 1.5, 5.0, 1), + RenderProfile("Counter", "update", 1.5, 5.0, 2), + RenderProfile("Counter", "update", 1.5, 5.0, 3), + RenderProfile("Counter", "update", 1.5, 5.0, 4), + RenderProfile("Counter", "update", 1.5, 5.0, 5), + RenderProfile("Counter", "update", 1.5, 5.0, 6), + RenderProfile("Counter", "update", 1.5, 5.0, 7), + ] + all_profiles = test_a_profiles + test_b_profiles + # max(render_count) = 7, which is worst-case single test + from codeflash.languages.javascript.frameworks.react.benchmarking import _aggregate_render_count + + assert _aggregate_render_count(all_profiles) == 7 + + def test_profiler_code_includes_before_each_reset(self): + from codeflash.languages.javascript.frameworks.react.profiler import generate_render_counter_code + + code = generate_render_counter_code("MyComponent") + assert "beforeEach" in code + assert "_codeflash_render_count_MyComponent = 0;" in code + + +class TestMultiComponentBenchmarking: + def test_group_by_component(self): + from codeflash.languages.javascript.frameworks.react.benchmarking import _group_by_component + + profiles = [ + RenderProfile("Parent", "mount", 5.0, 10.0, 1), + RenderProfile("Parent", "update", 3.0, 10.0, 2), + RenderProfile("Child", "mount", 2.0, 5.0, 1), + RenderProfile("Child", "update", 1.0, 5.0, 5), + ] + grouped = _group_by_component(profiles) + assert len(grouped) == 2 + assert len(grouped["Parent"]) == 2 + assert len(grouped["Child"]) == 2 + + def test_compare_with_target_component(self): + original = [ + RenderProfile("Parent", "mount", 5.0, 10.0, 1), + RenderProfile("Parent", "update", 3.0, 10.0, 5), + RenderProfile("Child", "mount", 2.0, 5.0, 1), + RenderProfile("Child", "update", 1.0, 5.0, 10), + ] + optimized = [ + RenderProfile("Parent", "mount", 5.0, 10.0, 1), + RenderProfile("Parent", "update", 3.0, 10.0, 5), + RenderProfile("Child", "mount", 2.0, 5.0, 1), + RenderProfile("Child", "update", 0.5, 5.0, 2), + ] + benchmark = compare_render_benchmarks( + original, optimized, target_component_name="Parent" + ) + assert benchmark is not None + # Parent renders unchanged + assert benchmark.original_render_count == 5 + assert benchmark.optimized_render_count == 5 + # Child renders reduced (10 → 2) + assert benchmark.child_render_reduction == 8 + + def test_compare_without_target_uses_first_component(self): + """Backward compat: no target_component_name uses all profiles.""" + original = [ + RenderProfile("Counter", "mount", 5.0, 10.0, 1), + RenderProfile("Counter", "update", 3.0, 10.0, 5), + ] + optimized = [ + RenderProfile("Counter", "mount", 5.0, 10.0, 1), + RenderProfile("Counter", "update", 1.0, 10.0, 2), + ] + benchmark = compare_render_benchmarks(original, optimized) + assert benchmark is not None + assert benchmark.component_name == "Counter" + assert benchmark.child_render_reduction == 0 + + +class TestInteractionDurationParsing: + def test_parse_interaction_duration_markers(self): + from codeflash.languages.javascript.parse import parse_interaction_duration_markers + + stdout = ( + "!######REACT_INTERACTION_DURATION:Counter:15.50:3######!\n" + "!######REACT_INTERACTION_DURATION:Timer:8.25:1######!\n" + ) + profiles = parse_interaction_duration_markers(stdout) + assert len(profiles) == 2 + assert profiles[0].component_name == "Counter" + assert profiles[0].duration_ms == 15.50 + assert profiles[0].burst_count == 3 + assert profiles[1].component_name == "Timer" + assert profiles[1].duration_ms == 8.25 + assert profiles[1].burst_count == 1 + + def test_parse_empty_stdout(self): + from codeflash.languages.javascript.parse import parse_interaction_duration_markers + + assert parse_interaction_duration_markers("") == [] + + def test_compare_with_interaction_durations(self): + original = [RenderProfile("Counter", "update", 3.0, 10.0, 5)] + optimized = [RenderProfile("Counter", "update", 3.0, 10.0, 5)] + orig_durations = [InteractionDurationProfile("Counter", 50.0, 5)] + opt_durations = [InteractionDurationProfile("Counter", 20.0, 2)] + benchmark = compare_render_benchmarks( + original, optimized, + original_interaction_durations=orig_durations, + optimized_interaction_durations=opt_durations, + ) + assert benchmark is not None + assert benchmark.original_interaction_duration_ms == 50.0 + assert benchmark.optimized_interaction_duration_ms == 20.0 + assert benchmark.interaction_duration_reduction_pct == 60.0 + assert benchmark.original_burst_count == 5 + assert benchmark.optimized_burst_count == 2 + + +class TestRenderEfficiencyCriticNewSignals: + def test_accepts_child_render_reduction(self): + """Accept when target renders are unchanged but children render less.""" + assert render_efficiency_critic( + original_render_count=5, + optimized_render_count=5, + original_render_duration=10.0, + optimized_render_duration=10.0, + child_render_reduction=8, + ) is True + + def test_rejects_small_child_render_reduction(self): + """Reject when child reduction is below threshold.""" + assert render_efficiency_critic( + original_render_count=5, + optimized_render_count=5, + original_render_duration=10.0, + optimized_render_duration=10.0, + child_render_reduction=1, + ) is False + + def test_accepts_interaction_duration_reduction(self): + """Accept when interaction duration decreases significantly (debounce/throttle).""" + assert render_efficiency_critic( + original_render_count=5, + optimized_render_count=5, + original_render_duration=10.0, + optimized_render_duration=10.0, + original_interaction_duration_ms=100.0, + optimized_interaction_duration_ms=30.0, + ) is True + + def test_rejects_small_interaction_duration_reduction(self): + """Reject when interaction duration reduction is too small.""" + assert render_efficiency_critic( + original_render_count=5, + optimized_render_count=5, + original_render_duration=10.0, + optimized_render_duration=10.0, + original_interaction_duration_ms=100.0, + optimized_interaction_duration_ms=90.0, + ) is False + + +class TestRenderEfficiencyCriticTrustDuration: + def test_duration_ignored_when_trust_duration_false(self): + """Duration-only improvement should be rejected when trust_duration=False.""" + assert render_efficiency_critic( + original_render_count=10, + optimized_render_count=10, + original_render_duration=100.0, + optimized_render_duration=10.0, + trust_duration=False, + ) is False + + def test_duration_accepted_when_trust_duration_true(self): + """Duration-only improvement should be accepted when trust_duration=True (default).""" + assert render_efficiency_critic( + original_render_count=10, + optimized_render_count=10, + original_render_duration=100.0, + optimized_render_duration=10.0, + trust_duration=True, + ) is True + + def test_render_count_still_works_with_trust_duration_false(self): + """Render count reduction should still be accepted when trust_duration=False.""" + assert render_efficiency_critic( + original_render_count=50, + optimized_render_count=10, + original_render_duration=100.0, + optimized_render_duration=100.0, + trust_duration=False, + ) is True + + def test_dom_mutations_still_work_with_trust_duration_false(self): + """DOM mutation reduction should still be accepted when trust_duration=False.""" + assert render_efficiency_critic( + original_render_count=5, + optimized_render_count=5, + original_render_duration=10.0, + optimized_render_duration=10.0, + original_dom_mutations=100, + optimized_dom_mutations=50, + trust_duration=False, + ) is True + + def test_child_renders_still_work_with_trust_duration_false(self): + """Child render reduction should still be accepted when trust_duration=False.""" + assert render_efficiency_critic( + original_render_count=5, + optimized_render_count=5, + original_render_duration=10.0, + optimized_render_duration=10.0, + child_render_reduction=5, + trust_duration=False, + ) is True + + +class TestHasReactTestInteractions: + def test_detects_fire_event(self): + assert has_react_test_interactions("fireEvent.click(button);") is True + + def test_detects_user_event(self): + assert has_react_test_interactions("await userEvent.type(input, 'hello');") is True + + def test_detects_rerender(self): + assert has_react_test_interactions("rerender();") is True + + def test_detects_act(self): + assert has_react_test_interactions("act(() => { setState(5); });") is True + + def test_rejects_render_only(self): + assert has_react_test_interactions("const { container } = render();") is False + + def test_rejects_empty(self): + assert has_react_test_interactions("") is False + + +class TestHasHighDensityInteractions: + def test_detects_loop_with_fireEvent(self): + source = """ + for (let i = 0; i < 10; i++) { + fireEvent.click(button); + } + """ + assert has_high_density_interactions(source) is True + + def test_detects_loop_with_rerender(self): + source = """ + for (let i = 0; i < 10; i++) { + rerender(); + } + """ + assert has_high_density_interactions(source) is True + + def test_detects_many_sequential_interactions(self): + source = """ + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button); + """ + assert has_high_density_interactions(source) is True + + def test_rejects_single_interaction(self): + source = "fireEvent.click(button);" + assert has_high_density_interactions(source) is False + + def test_rejects_two_interactions(self): + source = """ + fireEvent.click(button); + fireEvent.click(button); + """ + assert has_high_density_interactions(source) is False + + def test_rejects_no_interactions(self): + assert has_high_density_interactions("render();") is False + + def test_detects_loop_with_userEvent(self): + source = """ + for (const phrase of phrases) { + userEvent.type(input, phrase); + } + """ + assert has_high_density_interactions(source) is True diff --git a/tests/react/test_profiler.py b/tests/react/test_profiler.py index f5e728682..9e90f27a4 100644 --- a/tests/react/test_profiler.py +++ b/tests/react/test_profiler.py @@ -2,11 +2,15 @@ from __future__ import annotations +from pathlib import Path + import pytest from codeflash.languages.javascript.frameworks.react.profiler import ( MARKER_PREFIX, generate_render_counter_code, + instrument_all_components_except, + instrument_all_components_for_tracing, ) from codeflash.languages.javascript.parse import ( REACT_RENDER_MARKER_PATTERN, @@ -109,3 +113,50 @@ def test_mixed_output(self): assert len(profiles) == 2 assert profiles[0].component_name == "Counter" assert profiles[1].render_count == 2 + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +class TestInstrumentAllComponentsExcept: + def _get_analyzer(self, file_path: Path): + from codeflash.languages.javascript.treesitter import get_analyzer_for_file + + return get_analyzer_for_file(file_path) + + def test_excludes_target_instruments_children(self): + """When excluding MemoizedList, only ListItem should be instrumented.""" + file_path = FIXTURES_DIR / "MemoizedList.tsx" + source = file_path.read_text("utf-8") + analyzer = self._get_analyzer(file_path) + result = instrument_all_components_except(source, file_path, analyzer, "MemoizedList") + # ListItem should be instrumented (has Profiler wrapping) + assert "_codeflashOnRender_ListItem" in result + # MemoizedList should NOT be instrumented + assert "_codeflashOnRender_MemoizedList" not in result + + def test_instruments_all_when_no_match(self): + """When excluding a non-existent component, all should be instrumented.""" + file_path = FIXTURES_DIR / "MemoizedList.tsx" + source = file_path.read_text("utf-8") + analyzer = self._get_analyzer(file_path) + result = instrument_all_components_except(source, file_path, analyzer, "NonExistent") + assert "_codeflashOnRender_ListItem" in result + assert "_codeflashOnRender_MemoizedList" in result + + def test_no_change_when_only_target(self): + """When file has only the target component, source is unchanged.""" + file_path = FIXTURES_DIR / "Counter.tsx" + source = file_path.read_text("utf-8") + analyzer = self._get_analyzer(file_path) + result = instrument_all_components_except(source, file_path, analyzer, "Counter") + assert result == source + + def test_instrument_all_includes_target(self): + """instrument_all_components_for_tracing should include the target component too.""" + file_path = FIXTURES_DIR / "MemoizedList.tsx" + source = file_path.read_text("utf-8") + analyzer = self._get_analyzer(file_path) + result = instrument_all_components_for_tracing(source, file_path, analyzer) + assert "_codeflashOnRender_ListItem" in result + assert "_codeflashOnRender_MemoizedList" in result diff --git a/tests/react/test_testgen.py b/tests/react/test_testgen.py index 16ee49cbb..9a15a97ee 100644 --- a/tests/react/test_testgen.py +++ b/tests/react/test_testgen.py @@ -8,6 +8,7 @@ ) from codeflash.languages.javascript.frameworks.react.testgen import ( generate_rerender_test_template, + has_high_density_interactions, post_process_react_tests, ) @@ -56,6 +57,40 @@ def test_no_user_event_if_no_interaction(self): assert "@testing-library/user-event" not in result +class TestPostProcessWarnsLowDensity: + def test_warns_on_low_density(self, caplog): + """Post-processing should warn when tests lack high-density interactions.""" + import logging + + source = ( + "import { render } from '@testing-library/react';\n" + "describe('MyComp', () => {\n" + " it('does one thing', () => { fireEvent.click(btn); });\n" + "});" + ) + with caplog.at_level(logging.WARNING): + post_process_react_tests(source, _make_info()) + assert "high-density" in caplog.text.lower() or "lack high-density" in caplog.text.lower() + + def test_no_warn_on_high_density(self, caplog): + """No warning when tests have loops with interactions.""" + import logging + + source = ( + "import { render } from '@testing-library/react';\n" + "describe('MyComp', () => {\n" + " it('rapid clicks', () => {\n" + " for (let i = 0; i < 10; i++) {\n" + " fireEvent.click(btn);\n" + " }\n" + " });\n" + "});" + ) + with caplog.at_level(logging.WARNING): + post_process_react_tests(source, _make_info()) + assert "high-density" not in caplog.text.lower() + + def _make_info() -> ReactComponentInfo: return ReactComponentInfo( function_name="MyComp", From 4bc89f2b9df36a6647296ae7f58d1f0d32d70ac9 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Wed, 18 Mar 2026 03:27:35 +0530 Subject: [PATCH 57/57] react changes for interactive pattern --- .../frameworks/react/benchmarking.py | 135 +++++++++++++++++- .../javascript/frameworks/react/discovery.py | 31 ++++ .../javascript/frameworks/react/testgen.py | 103 +++++++++++++ codeflash/languages/javascript/parse.py | 51 +++++++ codeflash/languages/javascript/support.py | 2 + codeflash/languages/javascript/test_runner.py | 79 ++++++++-- codeflash/models/models.py | 4 + codeflash/optimization/function_optimizer.py | 119 ++++++++++++++- codeflash/result/critic.py | 45 ++++-- codeflash/verification/test_runner.py | 2 + packages/codeflash/runtime/capture.js | 12 ++ tests/react/test_benchmarking.py | 27 +++- tests/react/test_testgen.py | 2 +- 13 files changed, 585 insertions(+), 27 deletions(-) diff --git a/codeflash/languages/javascript/frameworks/react/benchmarking.py b/codeflash/languages/javascript/frameworks/react/benchmarking.py index 8afe59d90..3d160ff59 100644 --- a/codeflash/languages/javascript/frameworks/react/benchmarking.py +++ b/codeflash/languages/javascript/frameworks/react/benchmarking.py @@ -15,7 +15,12 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from codeflash.languages.javascript.parse import DomMutationProfile, InteractionDurationProfile, RenderProfile + from codeflash.languages.javascript.parse import ( + DomMutationProfile, + InteractionDurationProfile, + InteractionRenderProfile, + RenderProfile, + ) logger = logging.getLogger(__name__) @@ -52,6 +57,29 @@ def _aggregate_avg_duration(profiles: list[RenderProfile]) -> float: return sum(p.actual_duration_ms for p in profiles) / len(profiles) +@dataclass(frozen=True) +class InteractionComparison: + """Per-interaction render count comparison.""" + + interaction_label: str + original_render_count: int + optimized_render_count: int + + @property + def reduction_pct(self) -> float: + if self.original_render_count == 0: + return 0.0 + return ( + (self.original_render_count - self.optimized_render_count) + / self.original_render_count + * 100 + ) + + @property + def improved(self) -> bool: + return self.optimized_render_count < self.original_render_count + + @dataclass(frozen=True) class RenderBenchmark: """Comparison of original vs optimized render metrics. @@ -83,6 +111,12 @@ class RenderBenchmark: optimized_interaction_duration_ms: float = 0.0 original_burst_count: int = 0 optimized_burst_count: int = 0 + # Per-interaction render comparisons + per_interaction_comparisons: tuple[InteractionComparison, ...] = () + + @property + def has_per_interaction_data(self) -> bool: + return len(self.per_interaction_comparisons) > 0 @property def render_count_reduction_pct(self) -> float: @@ -161,6 +195,81 @@ def has_interaction_duration_data(self) -> bool: return self.original_interaction_duration_ms > 0 or self.optimized_interaction_duration_ms > 0 +def validate_render_count_stability(runs: list[list[RenderProfile]]) -> str: + """Compare render counts across multiple runs to assess measurement confidence. + + Args: + runs: List of render profile lists, one per validation run. + + Returns: + "high" if counts are identical across all runs, + "low" if any component's render count varies by >= 2 across runs. + Falls back to "high" if there's only 1 run or no profiles. + """ + if len(runs) <= 1: + return "high" + + # Group by component across runs: {component_name: [max_render_count_per_run]} + per_component_counts: dict[str, list[int]] = {} + for run_profiles in runs: + by_comp = _group_by_component(run_profiles) + seen_components = set() + for comp_name, profiles in by_comp.items(): + seen_components.add(comp_name) + count = _aggregate_render_count(profiles) + per_component_counts.setdefault(comp_name, []).append(count) + # Components not seen in this run get 0 + for comp_name in per_component_counts: + if comp_name not in seen_components: + per_component_counts[comp_name].append(0) + + for comp_name, counts in per_component_counts.items(): + spread = max(counts) - min(counts) + if spread >= 2: + logger.warning( + "[REACT] Unstable render count for %s across %d runs: %s (spread=%d)", + comp_name, + len(runs), + counts, + spread, + ) + return "low" + if spread == 1: + logger.info( + "[REACT] Minor render count variance for %s across %d runs: %s (±1)", + comp_name, + len(runs), + counts, + ) + + return "high" + + +def _build_interaction_comparisons( + original_profiles: list[InteractionRenderProfile], + optimized_profiles: list[InteractionRenderProfile], +) -> tuple[InteractionComparison, ...]: + """Build per-interaction render comparisons from original and optimized profiles.""" + orig_by_label: dict[str, int] = {} + for p in original_profiles: + orig_by_label[p.interaction_label] = orig_by_label.get(p.interaction_label, 0) + p.render_count + opt_by_label: dict[str, int] = {} + for p in optimized_profiles: + opt_by_label[p.interaction_label] = opt_by_label.get(p.interaction_label, 0) + p.render_count + + all_labels = list(dict.fromkeys(list(orig_by_label.keys()) + list(opt_by_label.keys()))) + comparisons: list[InteractionComparison] = [] + for label in all_labels: + comparisons.append( + InteractionComparison( + interaction_label=label, + original_render_count=orig_by_label.get(label, 0), + optimized_render_count=opt_by_label.get(label, 0), + ) + ) + return tuple(comparisons) + + def compare_render_benchmarks( original_profiles: list[RenderProfile], optimized_profiles: list[RenderProfile], @@ -169,6 +278,8 @@ def compare_render_benchmarks( target_component_name: str | None = None, original_interaction_durations: list[InteractionDurationProfile] | None = None, optimized_interaction_durations: list[InteractionDurationProfile] | None = None, + original_interaction_renders: list[InteractionRenderProfile] | None = None, + optimized_interaction_renders: list[InteractionRenderProfile] | None = None, ) -> RenderBenchmark | None: """Compare original and optimized render profiles with phase awareness. @@ -252,6 +363,13 @@ def compare_render_benchmarks( ) opt_bursts = max((d.burst_count for d in optimized_interaction_durations), default=0) + # Build per-interaction render comparisons + interaction_comparisons: tuple[InteractionComparison, ...] = () + if original_interaction_renders and optimized_interaction_renders: + interaction_comparisons = _build_interaction_comparisons( + original_interaction_renders, optimized_interaction_renders + ) + return RenderBenchmark( component_name=component_name, original_render_count=orig_count, @@ -271,6 +389,7 @@ def compare_render_benchmarks( optimized_interaction_duration_ms=opt_interaction_ms, original_burst_count=orig_bursts, optimized_burst_count=opt_bursts, + per_interaction_comparisons=interaction_comparisons, ) @@ -325,4 +444,18 @@ def format_render_benchmark_for_pr(benchmark: RenderBenchmark) -> str: if benchmark.render_speedup_x > 1: lines.append(f"\nRender time improved **{benchmark.render_speedup_x:.1f}x**.") + # Per-interaction breakdown table + if benchmark.has_per_interaction_data: + lines.append("") + lines.append("#### Per-Interaction Breakdown") + lines.append("") + lines.append("| Interaction | Before | After | Change |") + lines.append("|-------------|--------|-------|--------|") + for ic in benchmark.per_interaction_comparisons: + change = f"{ic.reduction_pct:.1f}% fewer" if ic.improved else "no change" + lines.append( + f"| {ic.interaction_label} | {ic.original_render_count} renders " + f"| {ic.optimized_render_count} renders | {change} |" + ) + return "\n".join(lines) diff --git a/codeflash/languages/javascript/frameworks/react/discovery.py b/codeflash/languages/javascript/frameworks/react/discovery.py index a7f231488..41263a73d 100644 --- a/codeflash/languages/javascript/frameworks/react/discovery.py +++ b/codeflash/languages/javascript/frameworks/react/discovery.py @@ -236,6 +236,37 @@ def _extract_props_type(func: FunctionNode, source: str, analyzer: TreeSitterAna return None +# Virtualization library imports that require real layout for meaningful benchmarks +_VIRTUALIZATION_IMPORTS = re.compile( + r"""(?:from|import)\s+['"](?:""" + r"react-window|react-virtuoso|react-virtual|@tanstack/react-virtual" + r"|react-virtualized|@tanstack/virtual-core" + r""")['"]""", +) + +# Layout APIs that return zeros in jsdom +_LAYOUT_API_USAGE = re.compile( + r"\b(?:getBoundingClientRect|offsetWidth|offsetHeight|clientWidth|clientHeight" + r"|scrollTop|scrollHeight|scrollWidth|scrollLeft" + r"|IntersectionObserver|ResizeObserver)\b" +) + + +def needs_real_layout(source: str) -> bool: + """Detect whether a component depends on real layout APIs unavailable in jsdom. + + Returns True if the source imports virtualization libraries or uses layout + measurement APIs (getBoundingClientRect, offsetWidth, IntersectionObserver, etc.) + that return zeros/stubs in jsdom. + + When True, jsdom-based render benchmarks may be inaccurate. Callers should + log a warning; Playwright support is deferred. + """ + if _VIRTUALIZATION_IMPORTS.search(source): + return True + return bool(_LAYOUT_API_USAGE.search(source)) + + def _is_wrapped_in_memo(func: FunctionNode, source: str) -> bool: """Check if the component is already wrapped in React.memo or memo().""" # Check if the variable declaration wrapping this function uses memo() diff --git a/codeflash/languages/javascript/frameworks/react/testgen.py b/codeflash/languages/javascript/frameworks/react/testgen.py index d1b406317..9359068d9 100644 --- a/codeflash/languages/javascript/frameworks/react/testgen.py +++ b/codeflash/languages/javascript/frameworks/react/testgen.py @@ -164,6 +164,10 @@ def post_process_react_tests(test_source: str, component_info: ReactComponentInf count=1, ) + # Auto-inject per-interaction render tracking markers around fireEvent/userEvent calls. + # This gives per-interaction A/B signal without the LLM needing to know about it. + result = inject_interaction_markers(result) + # Warn if no tests contain interaction calls — mount-phase only markers are # not useful for measuring optimization effectiveness. if not has_react_test_interactions(result): @@ -173,6 +177,18 @@ def post_process_react_tests(test_source: str, component_info: ReactComponentInf component_info.function_name, ) + # Check interaction density — fewer than MIN_INTERACTION_CALLS total interactions + # means the test is unlikely to produce enough update-phase renders for reliable measurement. + interaction_count = count_interaction_calls(result) + if interaction_count < MIN_INTERACTION_CALLS: + logger.error( + "[REACT] Generated tests for %s have only %d interaction calls (minimum %d). " + "Render count measurement will have low confidence.", + component_info.function_name, + interaction_count, + MIN_INTERACTION_CALLS, + ) + # Warn if tests lack high-density interaction patterns (loops or 3+ sequential calls) if not has_high_density_interactions(result): logger.warning( @@ -184,6 +200,75 @@ def post_process_react_tests(test_source: str, component_info: ReactComponentInf return result +# Pattern to find the variable assigned from captureRenderPerf (await or sync) +# Matches: const result = await codeflash.captureRenderPerf(...) +# const { container } = await codeflash.captureRenderPerf(...) +# let result = codeflash.captureRenderPerf(...) +_CAPTURE_RENDER_RESULT_PATTERN = re.compile( + r"(?:const|let|var)\s+(?:\{[^}]+\}|(\w+))\s*=\s*(?:await\s+)?(?:\w+\.)?captureRenderPerf\(", +) + +# Pattern matching fireEvent.* or userEvent.* standalone calls (not in comments) +_INTERACTION_CALL_PATTERN = re.compile( + r"^(\s*)((?:await\s+)?(?:fireEvent\.\w+|userEvent\.\w+)\s*\([^)]*\))\s*;", + re.MULTILINE, +) + + +def _extract_interaction_label(call_text: str) -> str: + """Extract a short label from an interaction call, e.g. 'click' from 'fireEvent.click(...)'.""" + m = re.search(r"(?:fireEvent|userEvent)\.(\w+)", call_text) + return m.group(1) if m else "interaction" + + +def inject_interaction_markers(test_source: str) -> str: + """Inject _codeflashMarkInteraction() calls before each fireEvent/userEvent call. + + Only injects when captureRenderPerf is used (the result object has the method). + Assigns a label derived from the interaction type (click, change, type, etc.) + and a sequential counter for uniqueness. + """ + if "captureRenderPerf" not in test_source: + return test_source + + # Find the result variable name from captureRenderPerf assignment + # Support both: const result = ... and const { container, ...rest } = ... + result_var = None + capture_match = _CAPTURE_RENDER_RESULT_PATTERN.search(test_source) + if capture_match: + # Group 1 is the simple variable name; for destructuring we need a different approach + result_var = capture_match.group(1) + if not result_var: + # Look for destructuring pattern and use the first variable + destr_match = re.search( + r"(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:\w+\.)?captureRenderPerf\(", + test_source, + ) + if destr_match: + result_var = destr_match.group(1) + if not result_var: + # Can't determine result variable — skip injection + return test_source + + # Find all interaction calls and inject marker before each + interaction_counter: dict[str, int] = {} + lines = test_source.split("\n") + new_lines: list[str] = [] + for line in lines: + m = _INTERACTION_CALL_PATTERN.match(line) + if m: + indent = m.group(1) + call_text = m.group(2) + label = _extract_interaction_label(call_text) + interaction_counter[label] = interaction_counter.get(label, 0) + 1 + unique_label = f"{label}_{interaction_counter[label]}" + marker_line = f"{indent}{result_var}._codeflashMarkInteraction('{unique_label}');" + new_lines.append(marker_line) + new_lines.append(line) + + return "\n".join(new_lines) + + # Patterns that indicate a test triggers user interactions causing re-renders _INTERACTION_PATTERNS = re.compile( r"fireEvent\.|userEvent\.|\.rerender\(|rerender\(|act\(" @@ -200,6 +285,24 @@ def has_react_test_interactions(test_source: str) -> bool: return bool(_INTERACTION_PATTERNS.search(test_source)) +# Minimum interaction calls for reliable render count measurement +MIN_INTERACTION_CALLS = 3 + +# Pattern matching individual interaction calls (fireEvent.*, userEvent.*, .rerender(), rerender()) +_INTERACTION_CALL_COUNT_PATTERN = re.compile( + r"(?:fireEvent\.\w+|userEvent\.\w+|\.rerender\(|(? int: + """Count the number of interaction calls in a test source. + + Counts fireEvent.*, userEvent.*, and rerender() calls. Used to assess + whether tests produce enough update-phase renders for reliable measurement. + """ + return len(_INTERACTION_CALL_COUNT_PATTERN.findall(test_source)) + + # Patterns for loops containing interaction calls _LOOP_WITH_INTERACTION = re.compile( r"for\s*\([^)]*\)\s*\{[^}]*(?:fireEvent\.|userEvent\.|rerender\()", diff --git a/codeflash/languages/javascript/parse.py b/codeflash/languages/javascript/parse.py index 960d8dc77..e09117138 100644 --- a/codeflash/languages/javascript/parse.py +++ b/codeflash/languages/javascript/parse.py @@ -37,6 +37,9 @@ # Format: !######REACT_RENDER:{component}:{phase}:{actualDuration}:{baseDuration}:{renderCount}######! REACT_RENDER_MARKER_PATTERN = re.compile(r"!######REACT_RENDER:([^:]+):([^:]+):([^:]+):([^:]+):(\d+)######!") +# Validation run boundary marker (separates output from multiple validation runs) +REACT_VALIDATION_RUN_BOUNDARY = "!######REACT_VALIDATION_RUN_BOUNDARY######!" + # DOM mutation marker pattern # Format: !######DOM_MUTATIONS:{component}:{mutationCount}######! DOM_MUTATION_MARKER_PATTERN = re.compile(r"!######DOM_MUTATIONS:([^:]+):(\d+)######!") @@ -45,6 +48,10 @@ # Format: !######REACT_INTERACTION_DURATION:{component}:{durationMs}:{burstCount}######! REACT_INTERACTION_DURATION_PATTERN = re.compile(r"!######REACT_INTERACTION_DURATION:([^:]+):([^:]+):(\d+)######!") +# Per-interaction render count marker pattern +# Format: !######REACT_INTERACTION_RENDERS:{component}:{label}:{renderCount}######! +REACT_INTERACTION_RENDERS_PATTERN = re.compile(r"!######REACT_INTERACTION_RENDERS:([^:]+):([^:]+):(\d+)######!") + @dataclass(frozen=True) class RenderProfile: @@ -147,6 +154,50 @@ def parse_interaction_duration_markers(stdout: str) -> list[InteractionDurationP return profiles +@dataclass(frozen=True) +class InteractionRenderProfile: + """Per-interaction render count from a single boundary marker.""" + + component_name: str + interaction_label: str + render_count: int + + +def parse_interaction_render_markers(stdout: str) -> list[InteractionRenderProfile]: + """Parse per-interaction render count markers from test output. + + Returns a list of InteractionRenderProfile instances, one per marker found. + """ + profiles: list[InteractionRenderProfile] = [] + for match in REACT_INTERACTION_RENDERS_PATTERN.finditer(stdout): + try: + profiles.append( + InteractionRenderProfile( + component_name=match.group(1), + interaction_label=match.group(2), + render_count=int(match.group(3)), + ) + ) + except (ValueError, IndexError) as e: + logger.debug("Failed to parse interaction render marker: %s", e) + return profiles + + +def parse_per_run_render_profiles(stdout: str) -> list[list[RenderProfile]]: + """Split multi-run stdout by boundary markers and parse render profiles per run. + + When ``n_validation_runs > 1``, the test runner inserts + ``REACT_VALIDATION_RUN_BOUNDARY`` markers between runs. This function + splits on those boundaries and parses each segment independently. + + Returns a list of render profile lists (one per validation run). + If no boundary markers are found, returns a single-element list with + the profiles from the entire stdout. + """ + segments = stdout.split(REACT_VALIDATION_RUN_BOUNDARY) + return [parse_react_render_markers(segment) for segment in segments] + + def _extract_jest_console_output(suite_elem: Any) -> str: """Extract console output from Jest's JUnit XML system-out element. diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index 113b08870..e57bdf7b5 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -2432,6 +2432,7 @@ def run_benchmarking_tests( target_duration_seconds: float = 10.0, test_framework: str | None = None, is_react_component: bool = False, + n_validation_runs: int = 1, ) -> tuple[Path, Any]: """Run benchmarking tests using the detected test framework. @@ -2482,6 +2483,7 @@ def run_benchmarking_tests( max_loops=max_loops, target_duration_ms=int(target_duration_seconds * 1000), is_react_component=is_react_component, + n_validation_runs=n_validation_runs, ) def run_line_profile_tests( diff --git a/codeflash/languages/javascript/test_runner.py b/codeflash/languages/javascript/test_runner.py index 50ca1a6ee..5279f46aa 100644 --- a/codeflash/languages/javascript/test_runner.py +++ b/codeflash/languages/javascript/test_runner.py @@ -1064,6 +1064,7 @@ def run_jest_benchmarking_tests( target_duration_ms: int = 10_000, # 10 seconds for benchmarking tests stability_check: bool = True, is_react_component: bool = False, + n_validation_runs: int = 1, ) -> tuple[Path, subprocess.CompletedProcess[str]]: """Run Jest benchmarking tests with in-process session-level looping. @@ -1075,6 +1076,11 @@ def run_jest_benchmarking_tests( - Timing data is collected per iteration - Stability is checked within the runner + For React components with n_validation_runs > 1, runs the test suite + multiple times and concatenates all stdout. Each run's render markers + are separated by ``!######REACT_VALIDATION_RUN_BOUNDARY######!`` markers + so the caller can split and compare render counts across runs. + Args: test_paths: TestFiles object containing test file information. test_env: Environment variables for the test run. @@ -1085,6 +1091,10 @@ def run_jest_benchmarking_tests( max_loops: Maximum number of loop iterations. target_duration_ms: Target TOTAL duration in milliseconds for all loops. stability_check: Whether to enable stability-based early stopping. + is_react_component: Whether the target is a React component. + n_validation_runs: Number of times to run the test suite for render + count validation (React only). Each run's output is concatenated + with boundary markers. Returns: Tuple of (result_file_path, subprocess_result with stdout from all iterations). @@ -1211,25 +1221,70 @@ def run_jest_benchmarking_tests( f"target_duration={target_duration_ms}ms, stability_check={stability_check}" ) + # Determine effective number of validation runs (only >1 for React) + effective_validation_runs = n_validation_runs if is_react_component and n_validation_runs > 1 else 1 + total_start_time = time.time() try: run_args = get_cross_platform_subprocess_run_args( cwd=effective_cwd, env=jest_env, timeout=total_timeout, check=False, text=True, capture_output=True ) - result = subprocess.run(jest_cmd, **run_args) # noqa: PLW1510 - # Combine stderr into stdout for timing markers - stdout = result.stdout or "" - if result.stderr: - stdout = stdout + "\n" + result.stderr if stdout else result.stderr - - # Create result with combined stdout - result = subprocess.CompletedProcess(args=result.args, returncode=result.returncode, stdout=stdout, stderr="") - if result.returncode != 0: - logger.debug(f"Jest benchmarking failed with return code {result.returncode}") - logger.debug(f"Jest benchmarking stdout: {result.stdout}") - logger.debug(f"Jest benchmarking stderr: {result.stderr}") + if effective_validation_runs == 1: + result = subprocess.run(jest_cmd, **run_args) # noqa: PLW1510 + + stdout = result.stdout or "" + if result.stderr: + stdout = stdout + "\n" + result.stderr if stdout else result.stderr + + result = subprocess.CompletedProcess( + args=result.args, returncode=result.returncode, stdout=stdout, stderr="" + ) + if result.returncode != 0: + logger.debug(f"Jest benchmarking failed with return code {result.returncode}") + logger.debug(f"Jest benchmarking stdout: {result.stdout}") + logger.debug(f"Jest benchmarking stderr: {result.stderr}") + else: + # Multi-run validation for React: run N times, concatenate output with boundary markers + logger.debug( + f"Running {effective_validation_runs} validation runs for React render count stability" + ) + combined_stdout_parts: list[str] = [] + last_returncode = 0 + last_args = jest_cmd + + for run_idx in range(effective_validation_runs): + run_result = subprocess.run(jest_cmd, **run_args) # noqa: PLW1510 + + run_stdout = run_result.stdout or "" + if run_result.stderr: + run_stdout = run_stdout + "\n" + run_result.stderr if run_stdout else run_result.stderr + + combined_stdout_parts.append(run_stdout) + # Add boundary marker between runs (not after the last one) + if run_idx < effective_validation_runs - 1: + combined_stdout_parts.append( + "\n!######REACT_VALIDATION_RUN_BOUNDARY######!\n" + ) + + last_returncode = run_result.returncode + last_args = run_result.args + + if run_result.returncode != 0: + logger.debug( + f"Jest benchmarking run {run_idx + 1}/{effective_validation_runs} " + f"failed with return code {run_result.returncode}" + ) + + logger.debug( + f"Validation run {run_idx + 1}/{effective_validation_runs} complete" + ) + + combined_stdout = "".join(combined_stdout_parts) + result = subprocess.CompletedProcess( + args=last_args, returncode=last_returncode, stdout=combined_stdout, stderr="" + ) except subprocess.TimeoutExpired: logger.warning(f"Jest benchmarking timed out after {total_timeout}s") diff --git a/codeflash/models/models.py b/codeflash/models/models.py index 7798437c3..dac39246d 100644 --- a/codeflash/models/models.py +++ b/codeflash/models/models.py @@ -404,6 +404,8 @@ class OptimizedCandidateResult(BaseModel): render_profiles: Optional[list[Any]] = None dom_mutations: Optional[list[Any]] = None interaction_durations: Optional[list[Any]] = None + interaction_render_profiles: Optional[list[Any]] = None + render_count_confidence: str = "high" class GeneratedTests(BaseModel): @@ -640,6 +642,8 @@ class OriginalCodeBaseline(BaseModel): render_profiles: Optional[list[Any]] = None dom_mutations: Optional[list[Any]] = None interaction_durations: Optional[list[Any]] = None + interaction_render_profiles: Optional[list[Any]] = None + render_count_confidence: str = "high" class CoverageStatus(Enum): diff --git a/codeflash/optimization/function_optimizer.py b/codeflash/optimization/function_optimizer.py index 2d305e968..6946acc6d 100644 --- a/codeflash/optimization/function_optimizer.py +++ b/codeflash/optimization/function_optimizer.py @@ -575,7 +575,7 @@ def parse_render_profiles_from_results(self, test_results: TestResults) -> list if not self.is_react_component or not test_results.perf_stdout: return None try: - from codeflash.languages.javascript.parse import parse_react_render_markers + from codeflash.languages.javascript.parse import parse_react_render_markers # noqa: PLC0415 profiles = parse_react_render_markers(test_results.perf_stdout) if profiles: @@ -589,6 +589,26 @@ def parse_render_profiles_from_results(self, test_results: TestResults) -> list logger.debug("Failed to parse React render markers", exc_info=True) return None + def compute_render_count_confidence(self, test_results: TestResults) -> str: + """Compute render count confidence from multi-run validation output. + + Splits stdout by validation run boundaries and compares render counts + across runs. Returns "high" if identical, "low" if unstable. + """ + if not self.is_react_component or not test_results.perf_stdout: + return "high" + try: + from codeflash.languages.javascript.frameworks.react.benchmarking import validate_render_count_stability # noqa: PLC0415 + from codeflash.languages.javascript.parse import parse_per_run_render_profiles # noqa: PLC0415 + + per_run_profiles = parse_per_run_render_profiles(test_results.perf_stdout) + if len(per_run_profiles) <= 1: + return "high" + return validate_render_count_stability(per_run_profiles) + except Exception: + logger.debug("Failed to compute render count confidence", exc_info=True) + return "high" + def parse_dom_mutations_from_results(self, test_results: TestResults) -> list | None: """Parse DOM mutation markers from test stdout.""" if not self.is_react_component or not test_results.perf_stdout: @@ -620,6 +640,21 @@ def parse_interaction_durations_from_results(self, test_results: TestResults) -> logger.debug("Failed to parse interaction duration markers", exc_info=True) return None + def parse_interaction_render_profiles_from_results(self, test_results: TestResults) -> list | None: + """Parse per-interaction render count markers from test stdout.""" + if not self.is_react_component or not test_results.perf_stdout: + return None + try: + from codeflash.languages.javascript.parse import parse_interaction_render_markers # noqa: PLC0415 + + profiles = parse_interaction_render_markers(test_results.perf_stdout) + if profiles: + logger.debug(f"Parsed {len(profiles)} per-interaction render profiles from test output") + return profiles + except Exception: + logger.debug("Failed to parse interaction render markers", exc_info=True) + return None + def can_be_optimized(self) -> Result[tuple[bool, CodeOptimizationContext, dict[Path, str]], str]: should_run_experiment = self.experiment_id is not None logger.info(f"!lsp|Function Trace ID: {self.function_trace_id}") @@ -1022,6 +1057,8 @@ def handle_successful_candidate( target_component_name=self.function_to_optimize.function_name, original_interaction_durations=original_code_baseline.interaction_durations, optimized_interaction_durations=candidate_result.interaction_durations, + original_interaction_renders=original_code_baseline.interaction_render_profiles, + optimized_interaction_renders=candidate_result.interaction_render_profiles, ) best_optimization = BestOptimization( @@ -1228,6 +1265,10 @@ def process_single_candidate( eval_ctx.record_successful_candidate(candidate.optimization_id, candidate_result.best_test_runtime, perf_gain) # Check if this is a successful optimization + low_confidence = ( + original_code_baseline.render_count_confidence == "low" + or candidate_result.render_count_confidence == "low" + ) is_successful_opt = speedup_critic( candidate_result, original_code_baseline.runtime, @@ -1237,6 +1278,7 @@ def process_single_candidate( original_concurrency_metrics=original_code_baseline.concurrency_metrics, best_concurrency_ratio_until_now=None, original_render_profiles=original_code_baseline.render_profiles, + render_count_low_confidence=low_confidence, ) and quantity_of_tests_critic(candidate_result) tree = self.build_runtime_info_tree( @@ -1974,6 +2016,27 @@ def generate_tests( f"[REACT-TESTGEN] {len(tests_without_interactions)} tests still lack interactions after retries" ) + # Check interaction density across all perf tests — if total interaction calls + # are below the minimum, preemptively flag low confidence. + from codeflash.languages.javascript.frameworks.react.testgen import ( # noqa: PLC0415 + MIN_INTERACTION_CALLS, + count_interaction_calls, + ) + + total_interactions = sum( + count_interaction_calls(t.instrumented_perf_test_source) for t in tests + ) + if total_interactions < MIN_INTERACTION_CALLS: + logger.error( + "[REACT-TESTGEN] Total interaction calls across all perf tests: %d (minimum %d). " + "Render count confidence will be set to low.", + total_interactions, + MIN_INTERACTION_CALLS, + ) + self.insufficient_test_interactions = True + else: + self.insufficient_test_interactions = False + if not tests: logger.warning(f"Failed to generate and instrument tests for {self.function_to_optimize.function_name}") return Failure(f"/!\\ NO TESTS GENERATED for {self.function_to_optimize.function_name}") @@ -2619,6 +2682,7 @@ def establish_original_code_baseline( enable_coverage=False, code_context=code_context, is_react_component=self.is_react_component, + n_validation_runs=3 if self.is_react_component else 1, ) logger.debug(f"[BENCHMARK-DONE] Got {len(benchmarking_results.test_results)} benchmark results") finally: @@ -2629,10 +2693,41 @@ def establish_original_code_baseline( self.function_to_optimize_source_code, original_helper_code, self.function_to_optimize.file_path ) - # Parse React render profiles, DOM mutations, and interaction durations from performance test stdout + # Parse React render profiles, DOM mutations, interaction durations, and per-interaction renders original_render_profiles = self.parse_render_profiles_from_results(benchmarking_results) original_dom_mutations = self.parse_dom_mutations_from_results(benchmarking_results) original_interaction_durations = self.parse_interaction_durations_from_results(benchmarking_results) + original_interaction_renders = self.parse_interaction_render_profiles_from_results(benchmarking_results) + original_render_confidence = self.compute_render_count_confidence(benchmarking_results) + + # Validate that baseline render profiles contain update-phase markers. + # Tests that only produce mount-phase markers cannot measure optimization effectiveness. + if self.is_react_component and original_render_profiles: + has_update_phase = any(p.phase == "update" for p in original_render_profiles) + if not has_update_phase: + logger.error( + "[REACT] Baseline render profiles contain zero update-phase markers. " + "Perf tests may lack interactions — render-based acceptance will require 30%% threshold." + ) + original_render_confidence = "low" + + # Propagate insufficient interaction count from testgen phase + if self.is_react_component and getattr(self, "insufficient_test_interactions", False): + original_render_confidence = "low" + + # Warn if the component uses layout APIs that jsdom cannot measure + if self.is_react_component: + try: + source = self.function_to_optimize.file_path.read_text("utf-8") + from codeflash.languages.javascript.frameworks.react.discovery import needs_real_layout # noqa: PLC0415 + + if needs_real_layout(source): + logger.warning( + "[REACT] Component uses layout APIs (virtualization, getBoundingClientRect, etc.) " + "— jsdom benchmarks may be inaccurate. Playwright support is planned." + ) + except Exception: + logger.debug("Failed to check layout API usage", exc_info=True) console.print( TestResults.report_to_tree( @@ -2702,6 +2797,8 @@ def establish_original_code_baseline( render_profiles=original_render_profiles, dom_mutations=original_dom_mutations, interaction_durations=original_interaction_durations, + interaction_render_profiles=original_interaction_renders, + render_count_confidence=original_render_confidence, ), functions_to_remove, ) @@ -2891,6 +2988,7 @@ def run_optimized_candidate( testing_time=total_looping_time, enable_coverage=False, is_react_component=self.is_react_component, + n_validation_runs=3 if self.is_react_component else 1, ) finally: self.restore_source_after_profiler(pre_profiler_source) @@ -2901,10 +2999,21 @@ def run_optimized_candidate( candidate_fto_code, candidate_helper_code, self.function_to_optimize.file_path ) - # Parse React render profiles, DOM mutations, and interaction durations from candidate performance test stdout + # Parse React render profiles, DOM mutations, interaction durations, and per-interaction renders candidate_render_profiles = self.parse_render_profiles_from_results(candidate_benchmarking_results) candidate_dom_mutations = self.parse_dom_mutations_from_results(candidate_benchmarking_results) candidate_interaction_durations = self.parse_interaction_durations_from_results(candidate_benchmarking_results) + candidate_interaction_renders = self.parse_interaction_render_profiles_from_results(candidate_benchmarking_results) + candidate_render_confidence = self.compute_render_count_confidence(candidate_benchmarking_results) + + if self.is_react_component and candidate_render_profiles: + has_update_phase = any(p.phase == "update" for p in candidate_render_profiles) + if not has_update_phase: + logger.error( + "[REACT] Candidate render profiles contain zero update-phase markers. " + "Render-based acceptance will require 30%% threshold." + ) + candidate_render_confidence = "low" # Use effective_loop_count which represents the minimum number of timing samples # across all test cases. This is more accurate for JavaScript tests where # capturePerf does internal looping with potentially different iteration counts per test. @@ -2958,6 +3067,8 @@ def run_optimized_candidate( render_profiles=candidate_render_profiles, dom_mutations=candidate_dom_mutations, interaction_durations=candidate_interaction_durations, + interaction_render_profiles=candidate_interaction_renders, + render_count_confidence=candidate_render_confidence, ) ) @@ -2975,6 +3086,7 @@ def run_and_parse_tests( code_context: CodeOptimizationContext | None = None, line_profiler_output_file: Path | None = None, is_react_component: bool = False, + n_validation_runs: int = 1, ) -> tuple[TestResults | dict, CoverageData | None]: coverage_database_file = None coverage_config_file = None @@ -3017,6 +3129,7 @@ def run_and_parse_tests( test_framework=self.test_cfg.test_framework, js_project_root=self.test_cfg.js_project_root, is_react_component=is_react_component, + n_validation_runs=n_validation_runs, ) else: msg = f"Unexpected testing type: {testing_type}" diff --git a/codeflash/result/critic.py b/codeflash/result/critic.py index 4cbe5887f..30d76d34b 100644 --- a/codeflash/result/critic.py +++ b/codeflash/result/critic.py @@ -73,6 +73,7 @@ def speedup_critic( original_concurrency_metrics: ConcurrencyMetrics | None = None, best_concurrency_ratio_until_now: float | None = None, original_render_profiles: list | None = None, + render_count_low_confidence: bool = False, ) -> bool: """Take in a correct optimized Test Result and decide if the optimization should actually be surfaced to the user. @@ -131,6 +132,7 @@ def speedup_critic( original_interaction_duration_ms=benchmark.original_interaction_duration_ms, optimized_interaction_duration_ms=benchmark.optimized_interaction_duration_ms, trust_duration=False, + low_confidence=render_count_low_confidence, ) throughput_improved = True # Default to True if no throughput data @@ -312,6 +314,7 @@ def render_efficiency_critic( original_interaction_duration_ms: float = 0.0, optimized_interaction_duration_ms: float = 0.0, trust_duration: bool = True, + low_confidence: bool = False, ) -> bool: """Evaluate whether a React optimization reduces re-renders, render time, or DOM mutations sufficiently. @@ -322,8 +325,12 @@ def render_efficiency_critic( When ``trust_duration`` is False (e.g. jsdom where actualDuration is noise), render duration is excluded from the acceptance criteria. + When ``low_confidence`` is True (render counts varied across validation + runs), the render count reduction threshold is raised from 20% to 30% + to reduce false positives from measurement noise. + Accepts if: - - Update render count reduced by >= 20% (primary), OR total render count reduced by >= 20% (fallback) + - Update render count reduced by >= threshold (primary), OR total render count reduced by >= threshold (fallback) - OR render duration reduced by >= MIN_IMPROVEMENT_THRESHOLD (when trust_duration=True) - OR DOM mutations reduced by >= 20% - OR child component render reduction >= MIN_CHILD_RENDER_REDUCTION (captures useCallback/memo optimizations) @@ -333,16 +340,38 @@ def render_efficiency_critic( if original_render_count == 0 and original_dom_mutations == 0 and child_render_reduction == 0: return False - # Use update-phase counts as primary signal when available + # Use update-phase counts as primary signal when available. + # When the ONLY signal is mount-phase render count (no update-phase data, no DOM mutations, + # no child reduction, no interaction data), we cannot meaningfully evaluate the optimization. + # Mount count reductions are not a valid React optimization signal — memoization optimizations + # often *increase* mount cost while reducing update-phase renders. + # When update-phase data exists, ONLY use it for render count acceptance — + # total count (which includes mount) dilutes the signal. has_update_data = original_update_render_count > 0 or optimized_update_render_count > 0 - effective_orig_count = original_update_render_count if has_update_data else original_render_count - effective_opt_count = optimized_update_render_count if has_update_data else optimized_render_count + has_dom_signal = original_dom_mutations > 0 + has_child_signal = child_render_reduction > 0 + has_interaction_signal = original_interaction_duration_ms > 0 + + if not has_update_data and not has_dom_signal and not has_child_signal and not has_interaction_signal: + return False - # Check render count reduction + # Check render count reduction (higher threshold when confidence is low) + render_count_threshold = 0.30 if low_confidence else MIN_RENDER_COUNT_REDUCTION_PCT count_improved = False - if effective_orig_count > 0: - count_reduction = (effective_orig_count - effective_opt_count) / effective_orig_count - count_improved = count_reduction >= MIN_RENDER_COUNT_REDUCTION_PCT + if has_update_data: + # Primary: update-phase only — do NOT fall through to total count + if original_update_render_count > 0: + count_reduction = ( + (original_update_render_count - optimized_update_render_count) / original_update_render_count + ) + count_improved = count_reduction >= render_count_threshold + elif original_render_count > 0: + # Fallback: total count when zero update-phase data exists + count_reduction = (original_render_count - optimized_render_count) / original_render_count + count_improved = count_reduction >= render_count_threshold + + # Determine effective counts for best-candidate tracking + effective_opt_count = optimized_update_render_count if has_update_data else optimized_render_count # Check render duration reduction (prefer update-phase duration) # Skipped when trust_duration=False (jsdom actualDuration is noise) diff --git a/codeflash/verification/test_runner.py b/codeflash/verification/test_runner.py index 89ae1db42..3eda66446 100644 --- a/codeflash/verification/test_runner.py +++ b/codeflash/verification/test_runner.py @@ -329,6 +329,7 @@ def run_benchmarking_tests( pytest_max_loops: int = 100_000, js_project_root: Path | None = None, is_react_component: bool = False, + n_validation_runs: int = 1, ) -> tuple[Path, subprocess.CompletedProcess]: logger.debug(f"run_benchmarking_tests called: framework={test_framework}, num_files={len(test_paths.test_files)}") # Check if there's a language support for this test framework that implements run_benchmarking_tests @@ -344,6 +345,7 @@ def run_benchmarking_tests( max_loops=pytest_max_loops, target_duration_seconds=pytest_target_runtime_seconds, is_react_component=is_react_component, + n_validation_runs=n_validation_runs, ) if is_python(): # pytest runs both pytest and unittest tests pytest_cmd_list = ( diff --git a/packages/codeflash/runtime/capture.js b/packages/codeflash/runtime/capture.js index 2a9054881..daffbb33e 100644 --- a/packages/codeflash/runtime/capture.js +++ b/packages/codeflash/runtime/capture.js @@ -1114,6 +1114,7 @@ function captureRenderPerf(funcName, lineId, renderFn, Component, ...createEleme const React = _getReact(); let renderCount = 0; + let renderCountAtLastBoundary = 0; function onRender(id, phase, actualDuration, baseDuration) { renderCount++; console.log(`!######REACT_RENDER:${funcName}:${phase}:${actualDuration}:${baseDuration}:${renderCount}######!`); @@ -1155,6 +1156,17 @@ function captureRenderPerf(funcName, lineId, renderFn, Component, ...createEleme }; } + // Per-interaction render tracking: records renders since last boundary + // and emits a REACT_INTERACTION_RENDERS marker for A/B comparison. + if (result) { + result._codeflashMarkInteraction = (label) => { + const rendersSinceLast = renderCount - renderCountAtLastBoundary; + console.log(`!######REACT_INTERACTION_RENDERS:${funcName}:${label}:${rendersSinceLast}######!`); + renderCountAtLastBoundary = renderCount; + return rendersSinceLast; + }; + } + return Promise.resolve(result); } diff --git a/tests/react/test_benchmarking.py b/tests/react/test_benchmarking.py index e997427fc..778ea83da 100644 --- a/tests/react/test_benchmarking.py +++ b/tests/react/test_benchmarking.py @@ -153,6 +153,8 @@ def test_accepts_significant_render_reduction(self): optimized_render_count=10, original_render_duration=100.0, optimized_render_duration=100.0, + original_update_render_count=48, + optimized_update_render_count=8, ) is True def test_rejects_insignificant_reduction(self): @@ -169,6 +171,8 @@ def test_accepts_significant_duration_improvement(self): optimized_render_count=10, original_render_duration=100.0, optimized_render_duration=10.0, + original_update_render_count=8, + optimized_update_render_count=8, ) is True def test_rejects_worse_than_best(self): @@ -187,6 +191,8 @@ def test_accepts_better_than_best(self): original_render_duration=100.0, optimized_render_duration=10.0, best_render_count_until_now=5, + original_update_render_count=48, + optimized_update_render_count=1, ) is True def test_uses_update_phase_counts_when_available(self): @@ -202,8 +208,19 @@ def test_uses_update_phase_counts_when_available(self): optimized_update_duration=10.0, ) is True - def test_falls_back_to_total_when_no_update_data(self): - # No update-phase data → uses total counts + def test_rejects_mount_only_when_no_secondary_signals(self): + # No update-phase data AND no DOM/child/interaction signals → rejected + assert render_efficiency_critic( + original_render_count=50, + optimized_render_count=10, + original_render_duration=100.0, + optimized_render_duration=100.0, + original_update_render_count=0, + optimized_update_render_count=0, + ) is False + + def test_falls_back_to_total_with_dom_signal(self): + # No update-phase data but DOM mutations present → uses total counts assert render_efficiency_critic( original_render_count=50, optimized_render_count=10, @@ -211,6 +228,8 @@ def test_falls_back_to_total_when_no_update_data(self): optimized_render_duration=100.0, original_update_render_count=0, optimized_update_render_count=0, + original_dom_mutations=100, + optimized_dom_mutations=20, ) is True @@ -427,6 +446,8 @@ def test_duration_accepted_when_trust_duration_true(self): optimized_render_count=10, original_render_duration=100.0, optimized_render_duration=10.0, + original_update_render_count=8, + optimized_update_render_count=8, trust_duration=True, ) is True @@ -437,6 +458,8 @@ def test_render_count_still_works_with_trust_duration_false(self): optimized_render_count=10, original_render_duration=100.0, optimized_render_duration=100.0, + original_update_render_count=48, + optimized_update_render_count=8, trust_duration=False, ) is True diff --git a/tests/react/test_testgen.py b/tests/react/test_testgen.py index 9a15a97ee..a5fe03325 100644 --- a/tests/react/test_testgen.py +++ b/tests/react/test_testgen.py @@ -47,7 +47,7 @@ def test_skips_if_already_imported(self): assert result.count("@testing-library/react") == 1 def test_adds_user_event_for_click(self): - source = "import { render } from '@testing-library/react';\ntest('clicks button', () => { click(button); });" + source = "import { render } from '@testing-library/react';\ntest('clicks button', () => { userEvent.click(button); });" result = post_process_react_tests(source, _make_info()) assert "@testing-library/user-event" in result