|
6 | 6 | import pytest |
7 | 7 |
|
8 | 8 | from sentry.integrations.github.platform_detection import ( |
| 9 | + FRAMEWORKS, |
9 | 10 | GITHUB_LANGUAGE_TO_SENTRY_PLATFORM, |
10 | 11 | DetectedPlatform, |
11 | 12 | DetectorRule, |
@@ -890,3 +891,172 @@ def get_side_effect(path, params=None): |
890 | 891 |
|
891 | 892 | platforms = [r["platform"] for r in result] |
892 | 893 | assert "go-gin" in platforms |
| 894 | + |
| 895 | + |
| 896 | +class TestFrameworksIntegrity: |
| 897 | + """Validate the FRAMEWORKS list is internally consistent. |
| 898 | +
|
| 899 | + Catches typos and structural errors that would silently cause |
| 900 | + framework definitions to never match at runtime. |
| 901 | + """ |
| 902 | + |
| 903 | + def test_no_duplicate_platform_ids(self) -> None: |
| 904 | + platform_ids = [fw["platform"] for fw in FRAMEWORKS] |
| 905 | + duplicates = [p for p in platform_ids if platform_ids.count(p) > 1] |
| 906 | + assert duplicates == [], f"Duplicate platform IDs: {set(duplicates)}" |
| 907 | + |
| 908 | + def test_all_base_platforms_are_valid(self) -> None: |
| 909 | + valid_base_platforms = set(GITHUB_LANGUAGE_TO_SENTRY_PLATFORM.values()) |
| 910 | + for fw in FRAMEWORKS: |
| 911 | + assert fw["base_platform"] in valid_base_platforms, ( |
| 912 | + f"{fw['platform']} has base_platform={fw['base_platform']!r} " |
| 913 | + f"which is not a value in GITHUB_LANGUAGE_TO_SENTRY_PLATFORM" |
| 914 | + ) |
| 915 | + |
| 916 | + def test_all_supersedes_targets_exist(self) -> None: |
| 917 | + all_platform_ids = {fw["platform"] for fw in FRAMEWORKS} |
| 918 | + all_base_platforms = set(GITHUB_LANGUAGE_TO_SENTRY_PLATFORM.values()) |
| 919 | + valid_targets = all_platform_ids | all_base_platforms |
| 920 | + |
| 921 | + for fw in FRAMEWORKS: |
| 922 | + for target in fw.get("supersedes", []): |
| 923 | + assert target in valid_targets, ( |
| 924 | + f"{fw['platform']} supersedes {target!r} " |
| 925 | + f"which does not exist as a framework or base platform" |
| 926 | + ) |
| 927 | + |
| 928 | + def test_every_framework_has_at_least_one_rule(self) -> None: |
| 929 | + for fw in FRAMEWORKS: |
| 930 | + has_rules = fw.get("every") or fw.get("some") |
| 931 | + assert has_rules, f"{fw['platform']} has no detection rules (no every or some)" |
| 932 | + |
| 933 | + def test_sort_values_are_positive_integers(self) -> None: |
| 934 | + for fw in FRAMEWORKS: |
| 935 | + assert isinstance(fw["sort"], int), ( |
| 936 | + f"{fw['platform']} sort={fw['sort']!r} is not an int" |
| 937 | + ) |
| 938 | + assert 1 <= fw["sort"] <= 99, ( |
| 939 | + f"{fw['platform']} sort={fw['sort']} is outside valid range 1-99" |
| 940 | + ) |
| 941 | + |
| 942 | + def test_no_rule_has_match_content_without_path(self) -> None: |
| 943 | + for fw in FRAMEWORKS: |
| 944 | + for rule in [*fw.get("every", []), *fw.get("some", [])]: |
| 945 | + if "match_content" in rule: |
| 946 | + assert "path" in rule, ( |
| 947 | + f"{fw['platform']} has match_content without path — " |
| 948 | + f"content matching requires a file to read" |
| 949 | + ) |
| 950 | + |
| 951 | + |
| 952 | +class TestDetectPlatformsMultiStack: |
| 953 | + """Test detection against a realistic multi-language, multi-framework repo. |
| 954 | +
|
| 955 | + Simulates a repo like a typical full-stack app with: |
| 956 | + - Python backend (Django + Celery) |
| 957 | + - JavaScript frontend (Next.js with React) |
| 958 | + - Go microservice (Gin) |
| 959 | + - Plus build/infra languages that should be ignored |
| 960 | + """ |
| 961 | + |
| 962 | + def test_full_stack_repo(self) -> None: |
| 963 | + client = mock.MagicMock() |
| 964 | + client.get_languages.return_value = { |
| 965 | + "Python": 120000, |
| 966 | + "JavaScript": 80000, |
| 967 | + "TypeScript": 60000, |
| 968 | + "Go": 40000, |
| 969 | + "HTML": 15000, |
| 970 | + "CSS": 10000, |
| 971 | + "Shell": 5000, |
| 972 | + "Makefile": 2000, |
| 973 | + "Dockerfile": 1000, |
| 974 | + } |
| 975 | + |
| 976 | + def get_side_effect(path, params=None): |
| 977 | + if path.endswith("/contents"): |
| 978 | + return [ |
| 979 | + {"name": "manage.py", "type": "file"}, |
| 980 | + {"name": "requirements.txt", "type": "file"}, |
| 981 | + {"name": "package.json", "type": "file"}, |
| 982 | + {"name": "go.mod", "type": "file"}, |
| 983 | + {"name": "next.config.js", "type": "file"}, |
| 984 | + {"name": "Dockerfile", "type": "file"}, |
| 985 | + {"name": "Makefile", "type": "file"}, |
| 986 | + {"name": "src", "type": "dir"}, |
| 987 | + {"name": "frontend", "type": "dir"}, |
| 988 | + {"name": "services", "type": "dir"}, |
| 989 | + ] |
| 990 | + if "requirements.txt" in path: |
| 991 | + return _make_b64_response( |
| 992 | + "Django==4.2\ncelery>=5.3\ngunicorn\npsycopg2-binary\nredis\n" |
| 993 | + ) |
| 994 | + if "package.json" in path: |
| 995 | + return _make_b64_response( |
| 996 | + json.dumps( |
| 997 | + { |
| 998 | + "dependencies": { |
| 999 | + "next": "^14.0.0", |
| 1000 | + "react": "^18.2.0", |
| 1001 | + "react-dom": "^18.2.0", |
| 1002 | + }, |
| 1003 | + "devDependencies": { |
| 1004 | + "typescript": "^5.0.0", |
| 1005 | + "eslint": "^8.0.0", |
| 1006 | + }, |
| 1007 | + } |
| 1008 | + ) |
| 1009 | + ) |
| 1010 | + if "go.mod" in path: |
| 1011 | + return _make_b64_response( |
| 1012 | + "module github.com/myorg/myapp\n\n" |
| 1013 | + "go 1.21\n\n" |
| 1014 | + "require (\n" |
| 1015 | + "\tgitlite.zycloud.tk/gin-gonic/gin v1.9.1\n" |
| 1016 | + "\tgitlite.zycloud.tk/lib/pq v1.10.9\n" |
| 1017 | + ")\n" |
| 1018 | + ) |
| 1019 | + raise ApiError("Not Found", code=404) |
| 1020 | + |
| 1021 | + client.get.side_effect = get_side_effect |
| 1022 | + |
| 1023 | + result = detect_platforms(client, "owner/repo") |
| 1024 | + platforms = [r["platform"] for r in result] |
| 1025 | + platform_set = set(platforms) |
| 1026 | + |
| 1027 | + # Frameworks detected with high confidence |
| 1028 | + assert "python-django" in platform_set |
| 1029 | + assert "python-celery" in platform_set |
| 1030 | + assert "javascript-nextjs" in platform_set |
| 1031 | + assert "go-gin" in platform_set |
| 1032 | + |
| 1033 | + # Supersession: React removed because Next.js is present |
| 1034 | + assert "javascript-react" not in platform_set |
| 1035 | + |
| 1036 | + # Base platforms as fallback |
| 1037 | + assert "python" in platform_set |
| 1038 | + assert "javascript" in platform_set |
| 1039 | + assert "go" in platform_set |
| 1040 | + |
| 1041 | + # Ignored languages excluded entirely |
| 1042 | + for r in result: |
| 1043 | + assert r["language"] not in ("HTML", "CSS", "Shell", "Makefile", "Dockerfile") |
| 1044 | + |
| 1045 | + # TypeScript deduplicates into javascript base platform |
| 1046 | + ts_results = [r for r in result if r["language"] == "TypeScript"] |
| 1047 | + assert ts_results == [] |
| 1048 | + |
| 1049 | + # Priority ordering: meta-frameworks first, then primary, then utilities, then base |
| 1050 | + nextjs = next(r for r in result if r["platform"] == "javascript-nextjs") |
| 1051 | + django = next(r for r in result if r["platform"] == "python-django") |
| 1052 | + celery = next(r for r in result if r["platform"] == "python-celery") |
| 1053 | + python_base = next(r for r in result if r["platform"] == "python") |
| 1054 | + |
| 1055 | + assert nextjs["priority"] > django["priority"] > celery["priority"] |
| 1056 | + assert celery["priority"] > python_base["priority"] |
| 1057 | + assert python_base["priority"] == 1 |
| 1058 | + |
| 1059 | + # Results are sorted by (priority, bytes) descending |
| 1060 | + for i in range(len(result) - 1): |
| 1061 | + a, b = result[i], result[i + 1] |
| 1062 | + assert (a["priority"], a["bytes"]) >= (b["priority"], b["bytes"]) |
0 commit comments