diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index 077df7be4..500c02839 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import re import subprocess import xml.etree.ElementTree as ET from pathlib import Path @@ -230,11 +231,14 @@ def discover_tests( """ result: dict[str, list[TestInfo]] = {} - # Build index: function_name → qualified_name for O(1) lookup - # This avoids iterating all functions for every test file (was O(NxM), now O(N+M)) + # Build indices for O(1) lookup per imported name (avoids O(NxM) loop) function_name_to_qualified: dict[str, str] = {} + class_name_to_qualified_names: dict[str, list[str]] = {} for func in source_functions: function_name_to_qualified[func.function_name] = func.qualified_name + for parent in func.parents: + if parent.type == "ClassDef": + class_name_to_qualified_names.setdefault(parent.name, []).append(func.qualified_name) # Find all test files using language-specific patterns test_patterns = self._get_test_patterns() @@ -249,28 +253,41 @@ def discover_tests( analyzer = get_analyzer_for_file(test_file) imports = analyzer.find_imports(source) - # Build a set of imported function names + # Build a set of imported names, resolving aliases and namespace member access imported_names: set[str] = set() for imp in imports: if imp.default_import: imported_names.add(imp.default_import) + # Extract member access patterns: e.g. `math.calculate(...)` → "calculate" + for m in re.finditer(rf"\b{re.escape(imp.default_import)}\.(\w+)", source): + imported_names.add(m.group(1)) + if imp.namespace_import: + imported_names.add(imp.namespace_import) + for m in re.finditer(rf"\b{re.escape(imp.namespace_import)}\.(\w+)", source): + imported_names.add(m.group(1)) for name, alias in imp.named_imports: - imported_names.add(alias or name) + imported_names.add(name) + if alias: + imported_names.add(alias) # Find test functions (describe/it/test blocks) test_functions = self._find_jest_tests(source, analyzer) - # Match source functions to tests using the index - # Only check functions that are actually imported in this test file + # Match via indices: function names and class names → qualified names + matched_qualified_names: set[str] = set() for imported_name in imported_names: if imported_name in function_name_to_qualified: - qualified_name = function_name_to_qualified[imported_name] - if qualified_name not in result: - result[qualified_name] = [] - for test_name in test_functions: - result[qualified_name].append( - TestInfo(test_name=test_name, test_file=test_file, test_class=None) - ) + matched_qualified_names.add(function_name_to_qualified[imported_name]) + if imported_name in class_name_to_qualified_names: + matched_qualified_names.update(class_name_to_qualified_names[imported_name]) + + for qualified_name in matched_qualified_names: + if qualified_name not in result: + result[qualified_name] = [] + for test_name in test_functions: + result[qualified_name].append( + TestInfo(test_name=test_name, test_file=test_file, test_class=None) + ) except Exception as e: logger.debug("Failed to analyze test file %s: %s", test_file, e) diff --git a/tests/languages/javascript/test_false_positive_discovery.py b/tests/languages/javascript/test_false_positive_discovery.py new file mode 100644 index 000000000..36e1cebc0 --- /dev/null +++ b/tests/languages/javascript/test_false_positive_discovery.py @@ -0,0 +1,109 @@ +"""Test for false positive test discovery bug (Bug #4).""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from codeflash.discovery.functions_to_optimize import FunctionToOptimize +from codeflash.languages.javascript.support import TypeScriptSupport +from codeflash.models.models import CodePosition + + +def test_discover_tests_should_not_match_mocked_functions(): + """Test that functions mentioned only in mocks are not matched as test targets. + + Regression test for Bug #4: False positive test discovery due to substring matching. + + When a test file mocks a function (e.g., vi.mock("./restart-request.js", () => ({...}))), + that function should NOT be considered as tested by that file, since it's only mocked, + not actually called or tested. + """ + support = TypeScriptSupport() + + with TemporaryDirectory() as tmpdir: + test_root = Path(tmpdir) + + # Create a test file that MOCKS parseRestartRequestParams but doesn't test it + test_file = test_root / "update.test.ts" + test_file.write_text( + ''' +import { updateSomething } from "./update.js"; + +vi.mock("./restart-request.js", () => ({ + parseRestartRequestParams: (params: any) => ({ sessionKey: undefined }), +})); + +describe("updateSomething", () => { + it("should update successfully", () => { + const result = updateSomething(); + expect(result).toBe(true); + }); +}); +''' + ) + + # Source function that is only mocked, not tested + source_function = FunctionToOptimize( + qualified_name="parseRestartRequestParams", + function_name="parseRestartRequestParams", + file_path=test_root / "restart-request.ts", + starting_line=1, + ending_line=10, + function_signature="", + code_position=CodePosition(line_no=1, col_no=0), + file_path_relative_to_project_root="restart-request.ts", + ) + + # Discover tests + result = support.discover_tests(test_root, [source_function]) + + # The bug: discovers update.test.ts as a test for parseRestartRequestParams + # because "parseRestartRequestParams" appears as a substring in the mock + # Expected: should NOT match (empty result) + assert ( + source_function.qualified_name not in result or len(result[source_function.qualified_name]) == 0 + ), f"Should not match mocked function, but found: {result.get(source_function.qualified_name, [])}" + + +def test_discover_tests_should_match_actually_imported_functions(): + """Test that functions actually imported and tested ARE correctly matched. + + This is the positive case to ensure we don't break legitimate test discovery. + """ + support = TypeScriptSupport() + + with TemporaryDirectory() as tmpdir: + test_root = Path(tmpdir) + + # Create a test file that ACTUALLY imports and tests the function + test_file = test_root / "restart-request.test.ts" + test_file.write_text( + ''' +import { parseRestartRequestParams } from "./restart-request.js"; + +describe("parseRestartRequestParams", () => { + it("should parse valid params", () => { + const result = parseRestartRequestParams({ sessionKey: "abc" }); + expect(result.sessionKey).toBe("abc"); + }); +}); +''' + ) + + source_function = FunctionToOptimize( + qualified_name="parseRestartRequestParams", + function_name="parseRestartRequestParams", + file_path=test_root / "restart-request.ts", + starting_line=1, + ending_line=10, + function_signature="", + code_position=CodePosition(line_no=1, col_no=0), + file_path_relative_to_project_root="restart-request.ts", + ) + + result = support.discover_tests(test_root, [source_function]) + + # Should match: function is imported and tested + assert source_function.qualified_name in result, f"Should match imported function, but got: {result}" + assert len(result[source_function.qualified_name]) > 0, "Should find at least one test"