diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 0bf07cec2..14c7410fc 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -56,44 +56,72 @@ jobs: # Create the target directory mkdir -p build/intermediates/runtime_library_classes_dir/debug - # Find all directories containing class files - CLASS_DIRS=$(find build -name "*.class" -type f -exec dirname {} \; | sort -u) + # Find all directories containing class files with better patterns + CLASS_DIRS=$(find build -name "*.class" -type f -exec dirname {} \; | sort -u | grep -E "(javac|kotlin-classes|runtime_library)" | head -10) if [ -z "$CLASS_DIRS" ]; then echo "WARNING: No class files found in the build directory!" + echo "Searching in all build subdirectories..." + find build -name "*.class" -type f | head -20 else echo "Found class files in the following directories:" echo "$CLASS_DIRS" - # Copy classes from the first directory with classes - FIRST_CLASS_DIR=$(echo "$CLASS_DIRS" | head -1) - echo "Copying classes from $FIRST_CLASS_DIR to build/intermediates/runtime_library_classes_dir/debug" - cp -r $FIRST_CLASS_DIR/* build/intermediates/runtime_library_classes_dir/debug/ || echo "Failed to copy from $FIRST_CLASS_DIR" + # Copy classes from all relevant directories, not just the first one + for CLASS_DIR in $CLASS_DIRS; do + if [ -d "$CLASS_DIR" ] && [ "$(find "$CLASS_DIR" -name "*.class" | wc -l)" -gt 0 ]; then + echo "Copying classes from $CLASS_DIR" + cp -r "$CLASS_DIR"/* build/intermediates/runtime_library_classes_dir/debug/ 2>/dev/null || echo "Failed to copy from $CLASS_DIR" + fi + done # Verify the target directory now has class files CLASS_COUNT=$(find build/intermediates/runtime_library_classes_dir/debug -name "*.class" | wc -l) echo "Target directory now contains $CLASS_COUNT class files" fi - # Update sonar-project.properties with all found class directories as a fallback - echo "\n# Additional binary paths found during build" >> sonar-project.properties - echo "sonar.java.binaries=build/intermediates/runtime_library_classes_dir/debug,$CLASS_DIRS" >> sonar-project.properties + # Update sonar-project.properties with all found class directories + echo "" >> sonar-project.properties + echo "# Additional binary paths found during build" >> sonar-project.properties + if [ -n "$CLASS_DIRS" ]; then + # Convert newlines to commas for sonar.java.binaries + BINARY_PATHS=$(echo "$CLASS_DIRS" | tr '\n' ',' | sed 's/,$//') + echo "sonar.java.binaries=build/intermediates/runtime_library_classes_dir/debug,$BINARY_PATHS" >> sonar-project.properties + else + echo "sonar.java.binaries=build/intermediates/runtime_library_classes_dir/debug" >> sonar-project.properties + fi echo "Checking for JaCoCo report files..." - find build -name "*.xml" | grep jacoco || echo "No XML files found" - find build -name "*.exec" | grep jacoco || echo "No exec files found" + find build -name "*.xml" | grep jacoco || echo "No JaCoCo XML files found" + find build -name "*.exec" | grep jacoco || echo "No JaCoCo exec files found" + echo "Contents of JaCoCo report directory:" ls -la build/reports/jacoco/jacocoTestReport/ || echo "Directory not found" - echo "\nChecking test execution results:" - find build -name "TEST-*.xml" | xargs cat | grep -E 'tests|failures|errors|skipped' || echo "No test result files found" + echo "" + echo "Checking test execution results:" + TEST_RESULT_FILES=$(find build -name "TEST-*.xml" 2>/dev/null) + if [ -n "$TEST_RESULT_FILES" ]; then + echo "Found test result files:" + echo "$TEST_RESULT_FILES" + # Count total tests, failures, errors + TOTAL_TESTS=$(cat $TEST_RESULT_FILES | grep -o 'tests="[0-9]*"' | cut -d'"' -f2 | awk '{sum+=$1} END {print sum}') + TOTAL_FAILURES=$(cat $TEST_RESULT_FILES | grep -o 'failures="[0-9]*"' | cut -d'"' -f2 | awk '{sum+=$1} END {print sum}') + TOTAL_ERRORS=$(cat $TEST_RESULT_FILES | grep -o 'errors="[0-9]*"' | cut -d'"' -f2 | awk '{sum+=$1} END {print sum}') + echo "Test summary: $TOTAL_TESTS tests, $TOTAL_FAILURES failures, $TOTAL_ERRORS errors" + else + echo "No test result files found" + fi - echo "\nChecking JaCoCo report content:" + echo "" + echo "Checking JaCoCo report content:" if [ -f build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml ]; then echo "Report file size: $(wc -c < build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml) bytes" echo "First 500 chars of report:" head -c 500 build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml - echo "\n\nCounting coverage elements:" + echo "" + echo "" + echo "Counting coverage elements:" grep -c " + def classDir = fileTree(dir: dirPath, excludes: fileFilter) + if (classDir.files.size() > 0) { + classDirectoriesFiles.add(classDir) + } + } + + // Include all source directories (Java and Kotlin) + sourceDirectories.from = files([ + 'src/main/java', + 'src/main/kotlin' + ]) + + // Include all found class files + classDirectories.from = files(classDirectoriesFiles) + + // Include execution data files from multiple possible locations executionData.from(fileTree(dir: "$buildDir", includes: [ 'jacoco/testDebugUnitTest.exec', - 'outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec' + 'outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec', + 'jacoco/*.exec', + 'outputs/**/**.exec' ])) outputs.upToDateWhen { false } doFirst { - // Log a warning if no execution data files exist + // Enhanced logging for debugging + logger.lifecycle("=== JaCoCo Report Generation ===") + + // Log source directories + logger.lifecycle("Source directories:") + sourceDirectories.files.each { dir -> + if (dir.exists()) { + logger.lifecycle(" - Found: $dir") + } else { + logger.lifecycle(" - Missing: $dir") + } + } + + // Log class directories + logger.lifecycle("Class directories:") + classDirectories.files.each { dir -> + logger.lifecycle(" - $dir (${dir.exists() ? 'exists' : 'missing'})") + } + + // Log execution data files def execFiles = executionData.files + logger.lifecycle("Execution data files:") if (execFiles.isEmpty() || !execFiles.any { it.exists() }) { - logger.warn("JaCoCo will not generate coverage report because no execution data files were found") + logger.warn(" - No execution data files found - coverage report will be empty") } else { execFiles.each { file -> if (file.exists()) { - logger.lifecycle("Found JaCoCo execution data file: $file") + logger.lifecycle(" - Found: $file (${file.length()} bytes)") + } else { + logger.lifecycle(" - Missing: $file") } } } + logger.lifecycle("================================") } } diff --git a/src/main/java/io/split/android/client/storage/db/SplitQueryDaoImpl.java b/src/main/java/io/split/android/client/storage/db/SplitQueryDaoImpl.java index 215c35a9b..66c5a2054 100644 --- a/src/main/java/io/split/android/client/storage/db/SplitQueryDaoImpl.java +++ b/src/main/java/io/split/android/client/storage/db/SplitQueryDaoImpl.java @@ -1,7 +1,6 @@ package io.split.android.client.storage.db; import android.database.Cursor; -import android.os.Process; import androidx.annotation.NonNull; @@ -23,12 +22,6 @@ public SplitQueryDaoImpl(SplitRoomDatabase mDatabase) { this.mDatabase = mDatabase; // Start prefilling the map in a background thread mInitializationThread = new Thread(() -> { - try { - android.os.Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); - } catch (Exception ignore) { - // Ignore - } - Map result = loadSplitsMap(); synchronized (mLock) { @@ -107,14 +100,15 @@ public void invalidate() { * This contains the actual loading logic separated from the caching/synchronization. */ private Map loadSplitsMap() { - final String sql = "SELECT name, body FROM splits"; + Cursor cursor = null; + try { + final String sql = "SELECT name, body FROM splits"; - Cursor cursor = mDatabase.query(sql, null); + cursor = mDatabase.query(sql, null); + + final int ESTIMATED_CAPACITY = 2000; + Map result = new HashMap<>(ESTIMATED_CAPACITY); - final int ESTIMATED_CAPACITY = 2000; - Map result = new HashMap<>(ESTIMATED_CAPACITY); - - try { final int nameIndex = getColumnIndexOrThrow(cursor, "name"); final int bodyIndex = getColumnIndexOrThrow(cursor, "body"); @@ -147,12 +141,20 @@ private Map loadSplitsMap() { entity.setBody(bodies[i]); result.put(names[i], entity); } + + return result; } catch (Exception e) { Logger.e("Error executing loadSplitsMap query: " + e.getLocalizedMessage()); } finally { - cursor.close(); + try { + if (cursor != null) { + cursor.close(); + } + } catch (Exception ignored) { + // ignore + } } - return result; + return new HashMap<>(); } } diff --git a/src/test/java/io/split/android/client/storage/db/SplitQueryDaoImplTest.java b/src/test/java/io/split/android/client/storage/db/SplitQueryDaoImplTest.java new file mode 100644 index 000000000..394d43679 --- /dev/null +++ b/src/test/java/io/split/android/client/storage/db/SplitQueryDaoImplTest.java @@ -0,0 +1,271 @@ +package io.split.android.client.storage.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import static java.util.Arrays.copyOfRange; + +import android.database.Cursor; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.OngoingStubbing; + +import java.util.Map; + +public class SplitQueryDaoImplTest { + + @Mock + private SplitRoomDatabase mockDatabase; + + @Mock + private Cursor mockCursor; + + private SplitQueryDaoImpl splitQueryDao; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void shouldInitializeSuccessfully() { + setupMockCursor(new String[]{"split1", "split2"}, new String[]{"body1", "body2"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + assertNotNull("SplitQueryDao should be initialized", splitQueryDao); + // Allow some time for background initialization + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Test + public void shouldReturnAllSplitsAsMap() { + setupMockCursor(new String[]{"split1", "split2"}, new String[]{"body1", "body2"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + // Wait for initialization + waitForInitialization(); + + Map result = splitQueryDao.getAllAsMap(); + + assertNotNull("Result should not be null", result); + assertEquals("Should return 2 splits", 2, result.size()); + assertTrue("Should contain split1", result.containsKey("split1")); + assertTrue("Should contain split2", result.containsKey("split2")); + assertEquals("Split1 body should match", "body1", result.get("split1").getBody()); + assertEquals("Split2 body should match", "body2", result.get("split2").getBody()); + } + + @Test + public void shouldReturnEmptyMapWhenNoSplits() { + setupMockCursor(new String[]{}, new String[]{}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + // Wait for initialization + waitForInitialization(); + + Map result = splitQueryDao.getAllAsMap(); + + assertNotNull("Result should not be null", result); + assertTrue("Result should be empty", result.isEmpty()); + } + + @Test + public void shouldHandleDatabaseError() { + when(mockDatabase.query(anyString(), any())).thenThrow(new RuntimeException("Database error")); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + // Wait for initialization + waitForInitialization(); + + Map result = splitQueryDao.getAllAsMap(); + + assertNotNull("Result should not be null", result); + assertTrue("Result should be empty when error occurs", result.isEmpty()); + } + + @Test + public void shouldInvalidateCache() { + setupMockCursor(new String[]{"split1"}, new String[]{"body1"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + // Wait for initialization + waitForInitialization(); + + // Get initial result + Map initialResult = splitQueryDao.getAllAsMap(); + assertFalse("Initial result should not be empty", initialResult.isEmpty()); + + splitQueryDao.invalidate(); + + // Setup new mock data for subsequent calls + setupMockCursor(new String[]{"split2"}, new String[]{"body2"}); + + Map resultAfterInvalidation = splitQueryDao.getAllAsMap(); + assertNotNull("Result after invalidation should not be null", resultAfterInvalidation); + } + + @Test + public void shouldReturnCopyOfMap() { + setupMockCursor(new String[]{"split1"}, new String[]{"body1"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + waitForInitialization(); + + Map result1 = splitQueryDao.getAllAsMap(); + Map result2 = splitQueryDao.getAllAsMap(); + + assertNotNull("First result should not be null", result1); + assertNotNull("Second result should not be null", result2); + assertEquals("Both results should have same content", result1.size(), result2.size()); + result1.clear(); + assertFalse("Second result should not be affected by clearing first", result2.isEmpty()); + } + + @Test + public void shouldHandleCursorCloseException() { + setupMockCursor(new String[]{"split1"}, new String[]{"body1"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + doThrow(new RuntimeException("Cursor close error")).when(mockCursor).close(); + + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + // Wait for initialization to complete (even with error) + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Should not crash and should return result from direct load + Map result = splitQueryDao.getAllAsMap(); + assertNotNull("Result should not be null", result); + } + + @Test + public void shouldGetColumnIndexCorrectly() { + setupMockCursor(new String[]{"split1"}, new String[]{"body1"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + when(mockCursor.getColumnIndex("name")).thenReturn(0); + int nameIndex = splitQueryDao.getColumnIndexOrThrow(mockCursor, "name"); + + assertEquals("Should return correct column index", 0, nameIndex); + } + + @Test + public void shouldHandleLargeBatchProcessing() { + String[] names = new String[250]; // More than BATCH_SIZE (100) + String[] bodies = new String[250]; + for (int i = 0; i < 250; i++) { + names[i] = "split" + i; + bodies[i] = "body" + i; + } + + setupMockCursor(names, bodies); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + waitForInitialization(); + + Map result = splitQueryDao.getAllAsMap(); + + assertNotNull("Result should not be null", result); + assertEquals("Should return all 250 splits", 250, result.size()); + assertTrue("Should contain first split", result.containsKey("split0")); + assertTrue("Should contain last split", result.containsKey("split249")); + } + + @Test + public void shouldWaitForInitializationWhenAccessedImmediately() { + setupMockCursor(new String[]{"split1"}, new String[]{"body1"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + Map result = splitQueryDao.getAllAsMap(); + + assertNotNull("Result should not be null", result); + // The method should either return the initialized map or load directly + verify(mockDatabase, times(1)).query(anyString(), any()); + } + + @Test + public void threadsShouldNotBlock() throws InterruptedException { + setupMockCursor(new String[]{"split1"}, new String[]{"body1"}); + when(mockDatabase.query(anyString(), any())).thenReturn(mockCursor); + splitQueryDao = new SplitQueryDaoImpl(mockDatabase); + + // Create a thread that will be interrupted while waiting + Thread testThread = new Thread(() -> { + Thread.currentThread().interrupt(); // Set interrupt flag + Map result = splitQueryDao.getAllAsMap(); + assertNotNull("Result should not be null even when interrupted", result); + }); + + testThread.start(); + testThread.join(1000); // Wait up to 1 second + + assertFalse("Test thread should have completed", testThread.isAlive()); + } + + private void setupMockCursor(String[] names, String[] bodies) { + when(mockCursor.getColumnIndex("name")).thenReturn(0); + when(mockCursor.getColumnIndex("body")).thenReturn(1); + + // Cursor movement + if (names.length == 0) { + when(mockCursor.moveToNext()).thenReturn(false); + } else { + Boolean[] moveResults = new Boolean[names.length + 1]; + for (int i = 0; i < names.length; i++) { + moveResults[i] = true; + } + moveResults[names.length] = false; // Final call returns false + + when(mockCursor.moveToNext()).thenReturn(moveResults[0], copyOfRange(moveResults, 1, moveResults.length)); + } + + if (names.length > 0) { + // Set up getString(0) for name column + OngoingStubbing nameStubbing = when(mockCursor.getString(0)); + for (String name : names) { + nameStubbing = nameStubbing.thenReturn(name); + } + + // Set up getString(1) for body column + OngoingStubbing bodyStubbing = when(mockCursor.getString(1)); + for (String body : bodies) { + bodyStubbing = bodyStubbing.thenReturn(body); + } + } + } + + private void waitForInitialization() { + try { + Thread.sleep(200); // Give time for background initialization + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +}