diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ba3736d0f..5fbcc01b40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,11 @@ on: env: DIFF_COVERAGE_THRESHOLD: '80' +permissions: + contents: read + pull-requests: write + issues: write + jobs: build: runs-on: ubuntu-24.04 @@ -26,35 +31,97 @@ jobs: - name: "[Test] SDK Unit Tests" working-directory: OneSignalSDK run: | - ./gradlew testReleaseUnitTest --console=plain --continue - - name: "[Coverage] Generate JaCoCo merged XML" - working-directory: OneSignalSDK + ./gradlew testDebugUnitTest --console=plain --continue + - name: "[Diff Coverage] Check for bypass" + id: coverage_bypass run: | - ./gradlew jacocoTestReportAll jacocoMergedReport --console=plain --continue - - name: "[Setup] Python" - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - name: "[Diff Coverage] Install diff-cover" - run: | - python -m pip install --upgrade pip diff-cover - - name: "[Diff Coverage] Check and HTML report" + # Check if PR has Skip Coverage Check label + if [ "${{ github.event_name }}" = "pull_request" ]; then + LABELS="${{ toJson(github.event.pull_request.labels.*.name) }}" + if echo "$LABELS" | grep -qiE "Skip Coverage Check|skip-coverage-check"; then + echo "bypass=true" >> $GITHUB_OUTPUT + echo "reason=PR has 'Skip Coverage Check' label" >> $GITHUB_OUTPUT + echo "⚠️ Coverage check will not fail build (PR has 'Skip Coverage Check' label)" + echo " Coverage will still be checked and reported" + else + echo "bypass=false" >> $GITHUB_OUTPUT + fi + else + echo "bypass=false" >> $GITHUB_OUTPUT + fi + - name: "[Diff Coverage] Check coverage" working-directory: OneSignalSDK run: | - REPORT=build/reports/jacoco/merged/jacocoMergedReport.xml - test -f "$REPORT" || { echo "Merged JaCoCo report not found at $REPORT" >&2; exit 1; } - python -m diff_cover.diff_cover_tool "$REPORT" \ - --compare-branch=origin/main \ - --fail-under=$DIFF_COVERAGE_THRESHOLD - python -m diff_cover.diff_cover_tool "$REPORT" \ - --compare-branch=origin/main \ - --html-report diff_coverage.html || true - - name: Upload diff coverage HTML - if: always() - uses: actions/upload-artifact@v4 + # Use the shared coverage check script for consistency + # Generate markdown report for PR comments + # If bypassed, still run the check but don't fail the build + set +e # Don't exit on error - we want to generate the report even if coverage fails + if [ "${{ steps.coverage_bypass.outputs.bypass }}" = "true" ]; then + SKIP_COVERAGE_CHECK=true GENERATE_MARKDOWN=true DIFF_COVERAGE_THRESHOLD=$DIFF_COVERAGE_THRESHOLD ./coverage/checkCoverage.sh + else + GENERATE_MARKDOWN=true DIFF_COVERAGE_THRESHOLD=$DIFF_COVERAGE_THRESHOLD ./coverage/checkCoverage.sh + fi + COVERAGE_EXIT_CODE=$? + set -e # Re-enable exit on error + + # Check if markdown report was generated + if [ -f "diff_coverage.md" ]; then + echo "✅ Coverage report generated" + else + echo "⚠️ Coverage report not generated" + fi + + # Only fail the build if coverage is below threshold AND not bypassed + if [ "${{ steps.coverage_bypass.outputs.bypass }}" != "true" ] && [ $COVERAGE_EXIT_CODE -ne 0 ]; then + echo "❌ Coverage check failed - build will fail" + exit $COVERAGE_EXIT_CODE + elif [ "${{ steps.coverage_bypass.outputs.bypass }}" = "true" ]; then + echo "⚠️ Coverage check completed (bypassed - build will not fail)" + exit 0 + else + echo "✅ Coverage check passed" + exit 0 + fi + - name: Comment PR with coverage summary + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 with: - name: diff-coverage-report - path: OneSignalSDK/diff_coverage.html + script: | + const fs = require('fs'); + const path = 'OneSignalSDK/diff_coverage.md'; + if (fs.existsSync(path)) { + const content = fs.readFileSync(path, 'utf8'); + const body = `## 📊 Diff Coverage Report\n\n${content}\n\n📥 [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`; + + // Find existing comment + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && comment.body.includes('Diff Coverage Report') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } + } - name: Unit tests results if: failure() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 3ff4ae3fb3..0cd7cbc570 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,7 @@ node_modules/ # Android Studio *.apk -*.ap_ \ No newline at end of file +*.ap_ + +# Coverage report +coverage/diff_coverage.html diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index 41a491f000..a3b8aa134f 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -109,4 +109,4 @@ gradle.projectsEvaluated { } // Apply JaCoCo configuration from separate file -apply from: 'jacoco.gradle' +apply from: 'coverage/jacoco.gradle' diff --git a/OneSignalSDK/coverage/COVERAGE_TESTING.md b/OneSignalSDK/coverage/COVERAGE_TESTING.md new file mode 100644 index 0000000000..17fe8631f0 --- /dev/null +++ b/OneSignalSDK/coverage/COVERAGE_TESTING.md @@ -0,0 +1,359 @@ +# Testing Coverage Locally + +You can test code coverage locally without pushing to CI/CD. Here are the commands: + +## Quick Start: Diff Coverage Check (Recommended) + +The easiest way to check coverage for your changed files is using the `checkCoverage.sh` script: + +```bash +# Run from the project root (OneSignalSDK/) +./coverage/checkCoverage.sh +``` + +This script will: +1. ✅ Generate coverage reports for all modules +2. ✅ Check coverage for files changed in your branch (compared to `origin/main`) +3. ✅ Show which files are below the 80% threshold +4. ✅ Generate an HTML report for detailed inspection + +### Configuration + +You can customize the script behavior with environment variables: + +```bash +# Set custom coverage threshold (default: 80%) +DIFF_COVERAGE_THRESHOLD=80 ./coverage/checkCoverage.sh + +# Compare against a different branch (default: origin/main) +BASE_BRANCH=origin/main ./coverage/checkCoverage.sh + +# Generate markdown report (for CI/CD compatibility) +GENERATE_MARKDOWN=true ./coverage/checkCoverage.sh + +# Bypass coverage check (local use only) +SKIP_COVERAGE_CHECK=true ./coverage/checkCoverage.sh +``` + +### Bypassing Coverage Check + +In some cases, you may need to merge a PR without adequate test coverage (e.g., emergency fixes, refactoring, or intentionally untested code). + +**Important:** When bypassed, the coverage check **still runs** and shows results in the PR comment. The build simply won't fail if coverage is below the threshold. This ensures visibility into coverage even when bypassing the requirement. + +There are three ways to bypass the coverage check: + +#### 1. PR Label (Recommended for CI/CD) +Add the `Skip Coverage Check` label to your PR. This is the recommended method as it's: +- ✅ Visible and auditable +- ✅ Easy to add/remove +- ✅ Works automatically in CI/CD +- ✅ Still shows coverage results in PR comment + +**How it works:** +1. Add the `Skip Coverage Check` label to your PR +2. CI/CD will run the coverage check as normal +3. Coverage results will be posted in the PR comment +4. If coverage is below threshold, a bypass notice will be added +5. The build will **not fail** even if coverage is low + +**Example PR Comment (when bypassed):** +``` +## Diff Coverage Report + +**Threshold:** 80% + +### Changed Files Coverage + +- ❌ IDManager.kt: 2/11 lines (18.2%) + - ⚠️ Below threshold: 9 uncovered lines + +### Overall Coverage + +**2/11** lines covered (18.2%) + +### ❌ Coverage Check Failed + +Files below 80% threshold: +- **IDManager.kt**: 18.2% (9 uncovered lines) + +--- +⚠️ **Coverage check bypassed - build will not fail** + +**Reason:** PR has 'Skip Coverage Check' label + +**Note:** Coverage results are shown above. Please ensure adequate test coverage is added in a follow-up PR when possible. +``` + +#### 2. Commit Message Keyword +Include one of these keywords in your commit message: +- `[skip coverage]` +- `[bypass coverage]` +- `[no coverage]` + +Example: +```bash +git commit -m "Emergency fix for critical bug [skip coverage]" +``` + +**How it works:** +- The script checks commit messages for these keywords +- If found, coverage check runs but build won't fail +- Coverage results are still shown in PR comment + +#### 3. Environment Variable (Local Testing Only) +For local testing, you can bypass the check: +```bash +SKIP_COVERAGE_CHECK=true ./coverage/checkCoverage.sh +``` + +**Note:** The environment variable method only works locally. In CI/CD, use the PR label or commit message method. + +### When to Use Bypass + +Use the bypass mechanism for: +- 🚨 **Emergency fixes** that need to be deployed immediately +- 🔄 **Refactoring** where code is moved but not changed +- 📝 **Documentation-only** changes +- 🧪 **Test infrastructure** changes +- ⚠️ **Intentionally untested code** (with follow-up plan) + +**Best Practice:** Always add a follow-up issue or comment explaining why coverage was bypassed and when it will be addressed. + +### Example Output + +#### Normal Run (Coverage Passes) +``` +======================================== +Diff Coverage Check +======================================== + +[1/3] Generating coverage reports... +✓ Coverage report generated + +[2/3] Checking diff coverage against origin/main... +Threshold: 80% + +Changed files: + OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IDManager.kt + + ✓ IDManager.kt: 10/11 lines (90.9%) + + Overall: 10/11 lines covered (90.9%) + + ✓ All files meet 80% threshold + +✓ Coverage check passed! +``` + +#### Normal Run (Coverage Fails) +``` +======================================== +Diff Coverage Check +======================================== + +[1/3] Generating coverage reports... +✓ Coverage report generated + +[2/3] Checking diff coverage against origin/main... +Threshold: 80% + +Changed files: + OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IDManager.kt + + ✗ IDManager.kt: 2/11 lines (18.2%) + + Overall: 2/11 lines covered (18.2%) + + Files below 80% threshold: + • IDManager.kt: 18.2% (9 uncovered lines) + +✗ Coverage check failed (files below 80% threshold) +``` + +#### Bypassed Run (Coverage Check Still Runs) +``` +======================================== +Diff Coverage Check +======================================== + +⚠ Coverage check will not fail build + Reason: SKIP_COVERAGE_CHECK environment variable set + Coverage will still be checked and reported + +[1/3] Generating coverage reports... +✓ Coverage report generated + +[2/3] Checking diff coverage against origin/main... +Threshold: 80% + +Changed files: + OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IDManager.kt + + ✗ IDManager.kt: 2/11 lines (18.2%) + + Overall: 2/11 lines covered (18.2%) + + Files below 80% threshold: + • IDManager.kt: 18.2% (9 uncovered lines) + +⚠ Coverage below threshold (files below 80%) + Build will not fail due to bypass: SKIP_COVERAGE_CHECK environment variable set + +======================================== +Coverage check complete! +======================================== +``` + +### What the Script Does + +The script uses a **manual coverage check** that reliably matches JaCoCo coverage data to git diff paths. This avoids the path matching issues that can occur with tools like `diff-cover`. + +- ✅ **Reliable**: Works consistently both locally and in CI/CD +- ✅ **Fast**: Only checks files that actually changed +- ✅ **Clear**: Shows exactly which files need more tests +- ✅ **Consistent**: Same logic used in CI/CD pipeline + +### Troubleshooting + +**"No Kotlin/Java files changed"** +- This means there are no `.kt` or `.java` files in your diff. The check passes automatically. + +**"Not in coverage report (may not be compiled/tested)"** +- The file exists in your diff but isn't in the coverage report. This usually means: + - The file wasn't compiled (check your build) + - The file isn't being tested (add tests) + - The file path doesn't match expected patterns + +**Script fails with "Coverage check failed"** +- One or more changed files have coverage below the threshold (default 80%) +- Check the output to see which files need more tests +- Add tests for the uncovered lines to increase coverage + +## Manual Coverage Testing + +If you want to manually test coverage for specific modules or view detailed reports: + +### Quick Test - Single Module (Core) + +To test coverage for just the core module where we added the new `IDManager` methods: + +```bash +# 1. Run tests with coverage +./gradlew :onesignal:core:testDebugUnitTest + +# 2. Generate coverage report +./gradlew :onesignal:core:jacocoTestReport + +# 3. View the HTML report (open in browser) +open onesignal/core/build/reports/jacoco/jacocoTestReport/html/index.html +``` + +## Full Coverage - All Modules + +To test coverage for all modules: + +```bash +# 1. Run all tests with coverage +./gradlew testDebugUnitTest + +# 2. Generate coverage reports for all modules +./gradlew jacocoTestReportAll + +# 3. Generate merged report (for CI/CD compatibility) +./gradlew jacocoMergedReport + +# 4. Print coverage summary to console +./gradlew jacocoTestReportSummary +``` + +## View Coverage Reports + +### HTML Reports (Visual) +Each module generates an HTML report you can view in your browser: + +```bash +# Core module +open onesignal/core/build/reports/jacoco/jacocoTestReport/html/index.html + +# Notifications module +open onesignal/notifications/build/reports/jacoco/jacocoTestReport/html/index.html + +# In-app messages module +open onesignal/in-app-messages/build/reports/jacoco/jacocoTestReport/html/index.html + +# Location module +open onesignal/location/build/reports/jacoco/jacocoTestReport/html/index.html +``` + +### XML Reports (For CI/CD tools) +XML reports are generated at: +- Individual modules: `onesignal/{module}/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml` +- Merged report: `build/reports/jacoco/merged/jacocoMergedReport.xml` + +## What to Look For + +### In the Console (Summary) + +When you run `./gradlew jacocoTestReportSummary`, you'll see overall coverage percentages in the console: + +``` +Module: core + Instructions: 17611/40425 (43.56%) + Branches: 884/2642 (33.46%) + Lines: 3013/6881 (43.79%) +``` + +This shows **overall** coverage for the module, but **not** specific method details. + +### In the HTML Report (Detailed View) + +To see the specific uncovered methods, you need to open the HTML report: + +1. Run: `./gradlew :onesignal:core:jacocoTestReport` +2. Open: `onesignal/core/build/reports/jacoco/jacocoTestReport/html/index.html` +3. Navigate to: `com.onesignal.common` → `IDManager` class + +In the HTML report, you'll see: +- **Green lines** (`fc` = fully covered): `createLocalId()` and `isLocalId()` +- **Red lines** (`nc` = not covered): The 4 new methods we added: + 1. `isValidId()` - Lines 38-41 (red, all branches missed) + 2. `extractUuid()` - Lines 52-55 (red, all branches missed) + 3. `isUuidFormat()` - Lines 66-67 (red) + 4. `createShortId()` - Line 77 (red) + +**Note:** The console summary shows overall percentages. To see which specific methods are uncovered, you need to view the HTML report. + +## Verify Coverage Detection + +The coverage tool should detect: +- **Covered**: `createLocalId()` and `isLocalId()` (existing methods that are used in tests) +- **Uncovered**: The 4 new methods we added (they have no tests) + +This confirms your coverage setup is working correctly! + +## One-Liner to Test Everything + +```bash +# Quick diff coverage check (recommended) +./coverage/checkCoverage.sh + +# Or manually test a single module +./gradlew :onesignal:core:testDebugUnitTest :onesignal:core:jacocoTestReport && open onesignal/core/build/reports/jacoco/jacocoTestReport/html/index.html +``` + +## CI/CD Integration + +The same `checkCoverage.sh` script is used in CI/CD to ensure consistency: + +- **Local**: Run `./coverage/checkCoverage.sh` to test before pushing +- **CI/CD**: Automatically runs on every pull request +- **Same Logic**: Both use identical coverage checking code + +The CI/CD pipeline will: +1. Run the script with `GENERATE_MARKDOWN=true` +2. Post a coverage summary as a PR comment +3. Fail the build if coverage is below the threshold (80%), unless bypassed (see [Bypassing Coverage Check](#bypassing-coverage-check) above) + +This ensures that code coverage is checked consistently whether you're testing locally or in CI/CD. + diff --git a/OneSignalSDK/coverage/checkCoverage.sh b/OneSignalSDK/coverage/checkCoverage.sh new file mode 100755 index 0000000000..1f0814c1f9 --- /dev/null +++ b/OneSignalSDK/coverage/checkCoverage.sh @@ -0,0 +1,300 @@ +#!/bin/bash + +# Diff Coverage Check Script +# This script generates coverage reports and checks diff coverage against the base branch +# Uses a manual coverage check that reliably matches JaCoCo paths to git diff paths +# +# Usage: +# ./coverage/checkCoverage.sh # Local use (console output) +# GENERATE_MARKDOWN=true ./coverage/checkCoverage.sh # CI/CD use (generates markdown) + +set -e # Exit on error (but we handle Python exit codes manually) + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +COVERAGE_THRESHOLD=${DIFF_COVERAGE_THRESHOLD:-80} +BASE_BRANCH=${BASE_BRANCH:-origin/main} +GENERATE_MARKDOWN=${GENERATE_MARKDOWN:-false} # Set to 'true' for CI/CD to generate markdown report +SKIP_COVERAGE_CHECK=${SKIP_COVERAGE_CHECK:-false} # Set to 'true' to bypass coverage check (still runs but doesn't fail) + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Paths relative to project root +COVERAGE_REPORT="$PROJECT_ROOT/build/reports/jacoco/merged/jacocoMergedReport.xml" +HTML_REPORT="$SCRIPT_DIR/diff_coverage.html" +MARKDOWN_REPORT="$PROJECT_ROOT/diff_coverage.md" + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE}Diff Coverage Check${NC}" +echo -e "${BLUE}========================================${NC}\n" + +# Check for bypass conditions (still run coverage check, but don't fail) +BYPASS_REASON="" +if [ "$SKIP_COVERAGE_CHECK" = "true" ]; then + BYPASS_REASON="SKIP_COVERAGE_CHECK environment variable set" +elif [ -n "$GITHUB_EVENT_NAME" ] && [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + # Check commit messages for bypass keyword + cd "$PROJECT_ROOT" + COMMIT_MESSAGES=$(git log --format=%B origin/main..HEAD 2>/dev/null || git log --format=%B "$BASE_BRANCH"..HEAD 2>/dev/null || echo "") + if echo "$COMMIT_MESSAGES" | grep -qiE "\[skip coverage\]|\[bypass coverage\]|\[no coverage\]"; then + BYPASS_REASON="Commit message contains [skip coverage] keyword" + fi +fi + +if [ -n "$BYPASS_REASON" ]; then + echo -e "${YELLOW}⚠ Coverage check will not fail build${NC}" + echo -e "${YELLOW} Reason: $BYPASS_REASON${NC}" + echo -e "${YELLOW} Coverage will still be checked and reported${NC}\n" +fi + +# Step 1: Generate coverage reports +echo -e "${YELLOW}[1/3] Generating coverage reports...${NC}" +cd "$PROJECT_ROOT" +./gradlew jacocoTestReportAll jacocoMergedReport --console=plain + +if [ ! -f "$COVERAGE_REPORT" ]; then + echo -e "${RED}✗ Error: Coverage report not found at $COVERAGE_REPORT${NC}" >&2 + exit 1 +fi +echo -e "${GREEN}✓ Coverage report generated${NC}\n" + +# Step 2: Check diff coverage using manual method (reliable path matching) +echo -e "${YELLOW}[2/3] Checking diff coverage against $BASE_BRANCH...${NC}" +echo -e "${YELLOW}Threshold: ${COVERAGE_THRESHOLD}%${NC}\n" + +# Get changed files (run from project root) +cd "$PROJECT_ROOT" +CHANGED_FILES=$(git diff --name-only "$BASE_BRANCH"...HEAD 2>/dev/null | grep -E '\.(kt|java)$' || true) + +if [ -z "$CHANGED_FILES" ]; then + echo -e "${BLUE}No Kotlin/Java files changed${NC}\n" + if [ "$GENERATE_MARKDOWN" = "true" ]; then + echo "✓ Coverage check passed (no source files changed)" > "$MARKDOWN_REPORT" + else + echo -e "${GREEN}✓ Coverage check passed (no source files changed)${NC}\n" + fi +else + echo -e "${BLUE}Changed files:${NC}" + echo "$CHANGED_FILES" | sed 's/^/ /' + echo "" + + # Manual coverage check (reliable path matching) + export COVERAGE_THRESHOLD + export COVERAGE_REPORT + export GENERATE_MARKDOWN + export MARKDOWN_REPORT + python3 << PYEOF +import xml.etree.ElementTree as ET +import re +import sys +import os + +coverage_report = os.environ.get('COVERAGE_REPORT') +threshold = int(os.environ.get('COVERAGE_THRESHOLD', '80')) +changed_files_str = """$CHANGED_FILES""" +generate_markdown = os.environ.get('GENERATE_MARKDOWN', 'false').lower() == 'true' +markdown_report = os.environ.get('MARKDOWN_REPORT', 'diff_coverage.md') + +try: + tree = ET.parse(coverage_report) + root = tree.getroot() +except Exception as e: + print(f"Error parsing coverage report: {e}") + sys.exit(1) + +changed_files = [f.strip() for f in changed_files_str.split('\n') if f.strip()] + +total_uncovered = 0 +total_lines = 0 +files_below_threshold = [] +files_checked = [] +markdown_output = [] + +if generate_markdown: + markdown_output.append("## Diff Coverage Report\n") + markdown_output.append(f"**Threshold:** {threshold}%\n\n") + markdown_output.append("### Changed Files Coverage\n\n") + +for changed_file in changed_files: + # Handle paths with or without OneSignalSDK/ prefix + if 'OneSignalSDK/' in changed_file: + path_part = changed_file.replace('OneSignalSDK/', '') + else: + path_part = changed_file + + # Extract package and filename from path + # e.g., onesignal/core/src/main/java/com/onesignal/common/IDManager.kt + # -> package: com/onesignal/common, filename: IDManager.kt + match = re.search(r'src/main/(java|kotlin)/(.+)/([^/]+\.(kt|java))$', path_part) + if not match: + continue + + package_path = match.group(2) + filename = match.group(3) + package_name = package_path.replace('/', '/') + + # Find in coverage report + found = False + for package in root.findall(f'.//package[@name="{package_name}"]'): + for sourcefile in package.findall(f'sourcefile[@name="{filename}"]'): + found = True + files_checked.append(filename) + + lines = sourcefile.findall('line') + file_total = len([l for l in lines if int(l.get('mi', 0)) > 0 or int(l.get('ci', 0)) > 0]) + file_covered = len([l for l in lines if int(l.get('ci', 0)) > 0]) + file_uncovered = len([l for l in lines if l.get('ci') == '0' and int(l.get('mi', 0)) > 0]) + + if file_total > 0: + total_lines += file_total + total_uncovered += file_uncovered + coverage_pct = (file_covered / file_total * 100) + + if generate_markdown: + status = "✅" if coverage_pct >= threshold else "❌" + markdown_output.append(f"- {status} **{filename}**: {file_covered}/{file_total} lines ({coverage_pct:.1f}%)") + if coverage_pct < threshold: + files_below_threshold.append((filename, coverage_pct, file_uncovered)) + markdown_output.append(f" - ⚠️ Below threshold: {file_uncovered} uncovered lines") + else: + status = "✓" if coverage_pct >= threshold else "✗" + color = "" if coverage_pct >= threshold else "\033[0;31m" + reset = "\033[0m" if color else "" + print(f" {color}{status}{reset} {filename}: {file_covered}/{file_total} lines ({coverage_pct:.1f}%)") + if coverage_pct < threshold: + files_below_threshold.append((filename, coverage_pct, file_uncovered)) + break + if found: + break + + if not found: + if generate_markdown: + markdown_output.append(f"- ⚠️ **{filename}**: Not in coverage report (may not be compiled/tested)") + else: + print(f" ⚠ {filename}: Not in coverage report (may not be compiled/tested)") + +if total_lines > 0: + overall_coverage = ((total_lines - total_uncovered) / total_lines * 100) + + if generate_markdown: + markdown_output.append(f"\n### Overall Coverage\n") + markdown_output.append(f"**{total_lines - total_uncovered}/{total_lines}** lines covered ({overall_coverage:.1f}%)\n") + + if files_below_threshold: + markdown_output.append(f"\n### ❌ Coverage Check Failed\n") + markdown_output.append(f"Files below {threshold}% threshold:\n") + for filename, pct, uncovered in files_below_threshold: + markdown_output.append(f"- **{filename}**: {pct:.1f}% ({uncovered} uncovered lines)\n") + + # Write markdown file + with open(markdown_report, 'w') as f: + f.write('\n'.join(markdown_output)) + + # Print to console + print('\n'.join(markdown_output)) + + if files_below_threshold: + sys.exit(1) + else: + sys.exit(0) + else: + print(f"\n Overall: {(total_lines - total_uncovered)}/{total_lines} lines covered ({overall_coverage:.1f}%)") + + if files_below_threshold: + print(f"\n Files below {threshold}% threshold:") + for filename, pct, uncovered in files_below_threshold: + print(f" • {filename}: {pct:.1f}% ({uncovered} uncovered lines)") + sys.exit(1) + else: + print(f"\n ✓ All files meet {threshold}% threshold") + sys.exit(0) +elif files_checked: + # Files were found but had no executable lines + if generate_markdown: + markdown_output.append(f"\n### ✅ Coverage Check Passed\n") + markdown_output.append("All checked files have no executable lines (or fully covered)\n") + with open(markdown_report, 'w') as f: + f.write('\n'.join(markdown_output)) + else: + print("\n ✓ All checked files have no executable lines (or fully covered)") + sys.exit(0) +else: + if generate_markdown: + markdown_output.append(f"\n### ⚠️ No Coverage Data\n") + markdown_output.append("No coverage data found for changed files\n") + with open(markdown_report, 'w') as f: + f.write('\n'.join(markdown_output)) + else: + print("\n ⚠ No coverage data found for changed files") + print(" This may mean files aren't being compiled or tested") + sys.exit(0) +PYEOF + + CHECK_RESULT=$? + if [ $CHECK_RESULT -eq 1 ]; then + if [ "$GENERATE_MARKDOWN" != "true" ]; then + if [ -n "$BYPASS_REASON" ]; then + echo -e "\n${YELLOW}⚠ Coverage below threshold (files below ${COVERAGE_THRESHOLD}%)${NC}" + echo -e "${YELLOW} Build will not fail due to bypass: $BYPASS_REASON${NC}\n" + else + echo -e "\n${RED}✗ Coverage check failed (files below ${COVERAGE_THRESHOLD}% threshold)${NC}\n" + fi + else + # In markdown mode, update the report to indicate bypass if applicable + if [ -n "$BYPASS_REASON" ] && [ -f "$PROJECT_ROOT/diff_coverage.md" ]; then + # Append bypass notice to existing markdown + echo "" >> "$PROJECT_ROOT/diff_coverage.md" + echo "---" >> "$PROJECT_ROOT/diff_coverage.md" + echo "⚠️ **Coverage check bypassed - build will not fail**" >> "$PROJECT_ROOT/diff_coverage.md" + echo "" >> "$PROJECT_ROOT/diff_coverage.md" + echo "**Reason:** $BYPASS_REASON" >> "$PROJECT_ROOT/diff_coverage.md" + echo "" >> "$PROJECT_ROOT/diff_coverage.md" + echo "**Note:** Coverage results are shown above. Please ensure adequate test coverage is added in a follow-up PR when possible." >> "$PROJECT_ROOT/diff_coverage.md" + fi + fi + # Only exit with error if not bypassed + if [ -z "$BYPASS_REASON" ]; then + exit 1 + else + exit 0 + fi + elif [ $CHECK_RESULT -eq 0 ]; then + if [ "$GENERATE_MARKDOWN" != "true" ]; then + echo -e "\n${GREEN}✓ Coverage check passed!${NC}\n" + fi + exit 0 + fi +fi + +# Step 3: Generate HTML report (optional, for visual inspection) +echo -e "${YELLOW}[3/3] Generating HTML coverage report...${NC}" +# Try to generate HTML report using diff-cover if available, otherwise skip +if python3 -m diff_cover.diff_cover_tool --version &>/dev/null 2>&1; then + # Try diff-cover for HTML report (may not work due to path issues, but worth trying) + cd "$PROJECT_ROOT" + python3 -m diff_cover.diff_cover_tool "build/reports/jacoco/merged/jacocoMergedReport.xml" \ + --compare-branch="$BASE_BRANCH" \ + --format html:"$HTML_REPORT" 2>&1 | grep -v "No lines with coverage" || true + + if [ -f "$HTML_REPORT" ]; then + echo -e "${GREEN}✓ HTML report generated: $HTML_REPORT${NC}" + echo -e "${BLUE} Open it in your browser to see detailed coverage${NC}\n" + else + echo -e "${YELLOW} HTML report generation had issues (non-fatal)${NC}\n" + fi +else + echo -e "${YELLOW} diff-cover not available, skipping HTML report${NC}" + echo -e "${BLUE} Install with: pip install diff-cover${NC}\n" +fi + +echo -e "${BLUE}========================================${NC}" +echo -e "${GREEN}Coverage check complete!${NC}" +echo -e "${BLUE}========================================${NC}" diff --git a/OneSignalSDK/jacoco.gradle b/OneSignalSDK/coverage/jacoco.gradle similarity index 100% rename from OneSignalSDK/jacoco.gradle rename to OneSignalSDK/coverage/jacoco.gradle diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IDManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IDManager.kt index 8bb997c601..863e1667f5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IDManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/IDManager.kt @@ -26,4 +26,54 @@ object IDManager { * @return true if the [id] provided was created via [createLocalId]. */ fun isLocalId(id: String): Boolean = id.startsWith(LOCAL_PREFIX) + + /** + * Validates if an ID has the correct format. + * This method is intentionally not tested to verify coverage detection. + * + * @param id The ID to validate. + * @return true if the ID is valid, false otherwise. + */ + fun isValidId(id: String?): Boolean { + if (id == null || id.isEmpty()) { + return false + } + return id.length >= 10 && id.matches(Regex("^[a-zA-Z0-9-]+$")) + } + + /** + * Extracts the UUID portion from a local ID. + * This method is intentionally not tested to verify coverage detection. + * + * @param localId The local ID to extract UUID from. + * @return The UUID string without the prefix, or null if invalid. + */ + fun extractUuid(localId: String): String? { + if (!isLocalId(localId)) { + return null + } + return localId.removePrefix(LOCAL_PREFIX) + } + + /** + * Checks if an ID is a valid UUID format. + * This method is intentionally not tested to verify coverage detection. + * + * @param id The ID to check. + * @return true if the ID matches UUID format, false otherwise. + */ + fun isUuidFormat(id: String): Boolean { + val uuidRegex = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + return uuidRegex.matches(id) + } + + /** + * Generates a short ID (8 characters) for testing purposes. + * This method is intentionally not tested to verify coverage detection. + * + * @return A short 8-character ID. + */ + fun createShortId(): String { + return UUID.randomUUID().toString().take(8).replace("-", "") + } }