diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57625181..800f5da3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: include: - os: macos-latest name: watchos - targets: watchosSimulatorArm64Test tvosSimulatorArm64Test + targets: watchosSimulatorArm64Test - os: macos-latest name: tvos targets: tvosSimulatorArm64Test diff --git a/build.gradle.kts b/build.gradle.kts index aab123be..4d465c62 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,6 @@ plugins { alias(libs.plugins.mavenPublishPlugin) apply false alias(libs.plugins.downloadPlugin) apply false alias(libs.plugins.kotlinter) apply false - alias(libs.plugins.keeper) apply false alias(libs.plugins.kotlin.atomicfu) apply false alias(libs.plugins.cocoapods) apply false alias(libs.plugins.ksp) apply false @@ -25,42 +24,6 @@ plugins { id("dokka-convention") } -allprojects { - repositories { - mavenCentral() - google() - maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") - maven("https://www.jetbrains.com/intellij-repository/releases") - maven("https://cache-redirector.jetbrains.com/intellij-dependencies") - // Repo for the backported Android IntelliJ Plugin by Jetbrains used in Ultimate - maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/kotlin-ide-plugin-dependencies/") - } - - configurations.configureEach { - exclude(group = "com.jetbrains.rd") - exclude(group = "com.github.jetbrains", module = "jetCheck") - exclude(group = "com.jetbrains.intellij.platform", module = "wsl-impl") - exclude(group = "org.roaringbitmap") - exclude(group = "com.jetbrains.infra") - exclude(group = "org.jetbrains.teamcity") - exclude(group = "org.roaringbitmap") - exclude(group = "ai.grazie.spell") - exclude(group = "ai.grazie.model") - exclude(group = "ai.grazie.utils") - exclude(group = "ai.grazie.nlp") - - // We have a transitive dependency on this due to Kermit, but need the fixed version to support Java 8 - resolutionStrategy.force("co.touchlab:stately-collections:${libs.versions.stately.get()}") - } -} -subprojects { - val GROUP: String by project - val LIBRARY_VERSION: String by project - - group = GROUP - version = LIBRARY_VERSION -} - tasks.getByName("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/core-tests-android/build.gradle.kts b/core-tests-android/build.gradle.kts index d422b643..9be72bfa 100644 --- a/core-tests-android/build.gradle.kts +++ b/core-tests-android/build.gradle.kts @@ -1,9 +1,6 @@ -import com.slack.keeper.optInToKeeper - plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.keeper) } dependencies { @@ -11,11 +8,11 @@ dependencies { implementation(libs.androidx.core) implementation(libs.androidx.appcompat) implementation(libs.androidx.material) + implementation(libs.test.coroutines) + implementation(libs.test.turbine) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.test.coroutines) - androidTestImplementation(libs.test.turbine) } android { @@ -35,7 +32,10 @@ android { buildTypes { release { isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) signingConfig = signingConfigs.getByName("debug") } } @@ -48,7 +48,3 @@ android { jvmTarget = "11" } } - -androidComponents { - beforeVariants { it.optInToKeeper() } -} diff --git a/core-tests-android/proguard-rules.pro b/core-tests-android/proguard-rules.pro index 481bb434..13d95dad 100644 --- a/core-tests-android/proguard-rules.pro +++ b/core-tests-android/proguard-rules.pro @@ -18,4 +18,20 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# This is the entrypoint in the app, we want to minify everything else to simulate a real app build. +-keep class com.powersync.testutils.IntegrationTestHelpers { + *; +} + +# Not used in main app, needed for testing +-keep class kotlin.** { + *; +} + +-keep class androidx.tracing.Trace { + public static void beginSection(java.lang.String); + public static void endSection(); + public static void forceEnableAppTracing(); +} diff --git a/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt b/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt index ef3800f7..d9212a15 100644 --- a/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt +++ b/core-tests-android/src/androidTest/java/com/powersync/AndroidDatabaseTest.kt @@ -1,235 +1,47 @@ package com.powersync +import com.powersync.testutils.IntegrationTestHelpers import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import app.cash.turbine.turbineScope -import com.powersync.db.schema.Schema -import com.powersync.testutils.UserRow -import kotlinx.coroutines.* -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class AndroidDatabaseTest { - private lateinit var database: PowerSyncDatabase + private val helpers = IntegrationTestHelpers( + InstrumentationRegistry.getInstrumentation().targetContext + ) @Before fun setupDatabase() { - database = - PowerSyncDatabase( - factory = DatabaseDriverFactory(InstrumentationRegistry.getInstrumentation().targetContext), - schema = Schema(UserRow.table), - dbFilename = "testdb", - ) - - runBlocking { - database.disconnectAndClear(true) - } + helpers.setup() } @After fun tearDown() { - runBlocking { - database.disconnectAndClear(true) - database.close() - } + helpers.tearDown() } @Test - fun testLinksPowerSync() = - runTest { - database.get("SELECT powersync_rs_version() AS r;") { it.getString(0)!! } - } + fun testLinksPowerSync() = helpers.testLinksPowerSync() @Test - fun testTableUpdates() = - runTest { - turbineScope { - val query = database.watch("SELECT * FROM users") { UserRow.from(it) }.testIn(this) - - // Wait for initial query - assertEquals(0, query.awaitItem().size) - - database.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf("Test", "test@example.org"), - ) - assertEquals(1, query.awaitItem().size) - - database.writeTransaction { - it.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf("Test2", "test2@example.org"), - ) - it.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf("Test3", "test3@example.org"), - ) - } - - assertEquals(3, query.awaitItem().size) - - try { - database.writeTransaction { - it.execute("DELETE FROM users;") - it.execute("syntax error, revert please") - } - } catch (e: Exception) { - // Ignore - } - - database.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf("Test4", "test4@example.org"), - ) - assertEquals(4, query.awaitItem().size) - - query.expectNoEvents() - query.cancel() - } - } + fun testTableUpdates() = helpers.testTableUpdates() @Test - fun testConcurrentReads() = - runTest { - database.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf( - "steven", - "s@journeyapps.com", - ), - ) - - val pausedTransaction = CompletableDeferred() - val transactionItemCreated = CompletableDeferred() - // Start a long running writeTransaction - val transactionJob = - async { - database.writeTransaction { tx -> - // Create another user - // External readers should not see this user while the transaction is open - tx.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf( - "steven", - "s@journeyapps.com", - ), - ) - - transactionItemCreated.complete(Unit) - - // Block this transaction until we free it - runBlocking { - pausedTransaction.await() - } - } - } - - // Make sure to wait for the item to have been created in the transaction - transactionItemCreated.await() - // Try and read while the write transaction is busy - val result = database.getAll("SELECT * FROM users") { UserRow.from(it) } - // The transaction is not commited yet, we should only read 1 user - assertEquals(result.size, 1) - - // Let the transaction complete - pausedTransaction.complete(Unit) - transactionJob.await() - - val afterTx = database.getAll("SELECT * FROM users") { UserRow.from(it) } - assertEquals(afterTx.size, 2) - } + fun testConcurrentReads() = helpers.testConcurrentReads() @Test - fun transactionReads() = - runTest { - database.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf( - "steven", - "s@journeyapps.com", - ), - ) - - database.writeTransaction { tx -> - val userCount = - tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } - assertEquals(userCount[0], 1) - - tx.execute( - "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", - listOf( - "steven", - "s@journeyapps.com", - ), - ) - - // Getters inside the transaction should be able to see the latest update - val userCount2 = - tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } - assertEquals(userCount2[0], 2) - } - } + fun transactionReads() = helpers.transactionReads() @Test - fun openDBWithDirectory() = - runTest { - val tempDir = - InstrumentationRegistry - .getInstrumentation() - .targetContext.cacheDir.canonicalPath - val dbFilename = "testdb" - - val db = - PowerSyncDatabase( - factory = DatabaseDriverFactory(InstrumentationRegistry.getInstrumentation().targetContext), - schema = Schema(UserRow.table), - dbDirectory = tempDir, - dbFilename = dbFilename, - ) - - val path = db.get("SELECT file FROM pragma_database_list;") { it.getString(0)!! } - - assertEquals(path.contains(tempDir), true) - - db.close() - } + fun openDBWithDirectory() = helpers.openDBWithDirectory() @Test - fun readConnectionsReadOnly() = - runTest { - val exception = - assertThrows(PowerSyncException::class.java) { - // This version of assertThrows does not support suspending functions - runBlocking { - database.getOptional( - """ - INSERT INTO - users (id, name, email) - VALUES - (uuid(), ?, ?) - RETURNING * - """.trimIndent(), - parameters = listOf("steven", "steven@journeyapps.com"), - ) {} - } - } - // The exception messages differ slightly between drivers - assertEquals(exception.message!!.contains("write a readonly database"), true) - } + fun readConnectionsReadOnly() = helpers.readConnectionsReadOnly() @Test - fun canUseTempStore() = runTest { - database.execute("PRAGMA temp_store = 1;") // Store temporary data as files - database.execute("CREATE TEMP TABLE foo (bar TEXT);") - val data = "new row".repeat(100); - for (i in 0..10000) { - database.execute("INSERT INTO foo VALUES (?)", parameters = listOf(data)) - } - } + fun canUseTempStore() = helpers.canUseTempStore() } diff --git a/core-tests-android/src/main/java/com/powersync/testutils/IntegrationTestHelpers.kt b/core-tests-android/src/main/java/com/powersync/testutils/IntegrationTestHelpers.kt new file mode 100644 index 00000000..9f829ded --- /dev/null +++ b/core-tests-android/src/main/java/com/powersync/testutils/IntegrationTestHelpers.kt @@ -0,0 +1,231 @@ +package com.powersync.testutils + +import android.content.Context +import app.cash.turbine.turbineScope +import com.powersync.DatabaseDriverFactory +import com.powersync.PowerSyncDatabase +import com.powersync.PowerSyncException +import com.powersync.db.schema.Schema +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest + +/** + * Everything needed to run integration tests, except the actual test framework. + * + * This allows us to only keep this class active with proguard, and assert that minifying the PowerSync + * SDK with Proguard works as intended. + */ +class IntegrationTestHelpers(private val context: Context) { + + private lateinit var database: PowerSyncDatabase + + fun setup() { + runBlocking { + database = + PowerSyncDatabase( + factory = DatabaseDriverFactory(context), + schema = Schema(UserRow.table), + dbFilename = "testdb", + ) + } + } + + fun tearDown() { + runBlocking { + database.disconnectAndClear(true) + database.close() + } + } + + + fun testLinksPowerSync() = + runTest { + database.get("SELECT powersync_rs_version() AS r;") { it.getString(0)!! } + } + + + fun testTableUpdates() = + runTest { + turbineScope { + val query = database.watch("SELECT * FROM users") { UserRow.from(it) }.testIn(this) + + // Wait for initial query + check(query.awaitItem().isEmpty()) { "Expected initial select to be empty" } + + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("Test", "test@example.org"), + ) + + check(query.awaitItem().size == 1) { "Expected second select to emit one item" } + + database.writeTransaction { + it.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("Test2", "test2@example.org"), + ) + it.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("Test3", "test3@example.org"), + ) + } + + check(query.awaitItem().size == 3) { "Expected three items after transaction" } + + try { + database.writeTransaction { + it.execute("DELETE FROM users;") + it.execute("syntax error, revert please") + } + } catch (e: Exception) { + // Ignore + } + + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("Test4", "test4@example.org"), + ) + check(query.awaitItem().size == 4) { "Expected four items after second transaction" } + + query.expectNoEvents() + query.cancel() + } + } + + fun testConcurrentReads() = + runTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + val pausedTransaction = CompletableDeferred() + val transactionItemCreated = CompletableDeferred() + // Start a long running writeTransaction + val transactionJob = + async { + database.writeTransaction { tx -> + // Create another user + // External readers should not see this user while the transaction is open + tx.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + transactionItemCreated.complete(Unit) + + // Block this transaction until we free it + runBlocking { + pausedTransaction.await() + } + } + } + + // Make sure to wait for the item to have been created in the transaction + transactionItemCreated.await() + // Try and read while the write transaction is busy + val result = database.getAll("SELECT * FROM users") { UserRow.from(it) } + // The transaction is not commited yet, we should only read 1 user + check(result.size == 1) { "Expected one user while transaction is still active" } + + // Let the transaction complete + pausedTransaction.complete(Unit) + transactionJob.await() + + val afterTx = database.getAll("SELECT * FROM users") { UserRow.from(it) } + check(afterTx.size == 2) { "Expected two users after transaction" } + } + + fun transactionReads() = + runTest { + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + database.writeTransaction { tx -> + val userCount = + tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } + + check(userCount[0] == 1L) + + tx.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf( + "steven", + "s@journeyapps.com", + ), + ) + + // Getters inside the transaction should be able to see the latest update + val userCount2 = + tx.getAll("SELECT COUNT(*) as count FROM users") { cursor -> cursor.getLong(0)!! } + check(userCount2[0] == 2L) + } + } + + fun openDBWithDirectory() = + runTest { + val tempDir = context.cacheDir.canonicalPath + val dbFilename = "testdb" + + val db = + PowerSyncDatabase( + factory = DatabaseDriverFactory(context), + schema = Schema(UserRow.table), + dbDirectory = tempDir, + dbFilename = dbFilename, + ) + + val path = db.get("SELECT file FROM pragma_database_list;") { it.getString(0)!! } + + check(path.contains(tempDir)) + + db.close() + } + + fun readConnectionsReadOnly() = + runTest { + val exception = try { + database.getOptional( + """ + INSERT INTO + users (id, name, email) + VALUES + (uuid(), ?, ?) + RETURNING * + """.trimIndent(), + parameters = listOf("steven", "steven@journeyapps.com"), + ) {} + + throw IllegalStateException("Expected write to throw") + } catch (expected: PowerSyncException) { + expected + } + + // The exception messages differ slightly between drivers, so we only use contains + check(exception.message!!.contains("write a readonly database")) { + "${exception.message} should contain 'write a readonly database'" + } + } + + fun canUseTempStore() = runTest { + database.execute("PRAGMA temp_store = 1;") // Store temporary data as files + database.execute("CREATE TEMP TABLE foo (bar TEXT);") + val data = "new row".repeat(100) + repeat(10000) { + database.execute("INSERT INTO foo VALUES (?)", parameters = listOf(data)) + } + } +} diff --git a/core-tests-android/src/androidTest/java/com/powersync/testutils/UserRow.kt b/core-tests-android/src/main/java/com/powersync/testutils/UserRow.kt similarity index 100% rename from core-tests-android/src/androidTest/java/com/powersync/testutils/UserRow.kt rename to core-tests-android/src/main/java/com/powersync/testutils/UserRow.kt diff --git a/demos/supabase-todolist/build.gradle.kts b/demos/supabase-todolist/build.gradle.kts index b39c10c9..06db43d9 100644 --- a/demos/supabase-todolist/build.gradle.kts +++ b/demos/supabase-todolist/build.gradle.kts @@ -12,8 +12,8 @@ if (localPropertiesFile.exists()) { val useReleasedVersions = localProperties.getProperty("USE_RELEASED_POWERSYNC_VERSIONS", "false") == "true" -subprojects { - if (useReleasedVersions) { +if (useReleasedVersions) { + subprojects { configurations.all { // https://docs.gradle.org/current/userguide/resolution_rules.html#sec:conditional-dependency-substitution resolutionStrategy.dependencySubstitution.all { @@ -39,4 +39,3 @@ subprojects { } } } - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cce7ecd4..967469af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,6 @@ maven-publish = "0.34.0" download-plugin = "5.6.0" mokkery = "3.0.0" kotlinter = "5.2.0" -keeper = "0.16.1" atomicfu = "0.29.0" buildKonfig = "0.17.1" @@ -152,7 +151,6 @@ mavenPublishPlugin = { id = "com.vanniktech.maven.publish", version.ref = "maven downloadPlugin = { id = "de.undercouch.download", version.ref = "download-plugin" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } kotlinter = { id = "org.jmailen.kotlinter", version.ref = "kotlinter" } -keeper = { id = "com.slack.keeper", version.ref = "keeper" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" } diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralUploadPlugin.kt b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralUploadPlugin.kt index 64d8c345..97481a61 100644 --- a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralUploadPlugin.kt +++ b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/sonatype/SonatypeCentralUploadPlugin.kt @@ -10,6 +10,9 @@ import com.vanniktech.maven.publish.MavenPublishBaseExtension internal class SonatypeCentralUploadPlugin : Plugin { override fun apply(project: Project) { project.logger.info("Applying the `gradle-maven-publish` plugin") + project.group = project.property("GROUP") as String + project.version = project.property("LIBRARY_VERSION") as String + project.plugins.apply(MavenPublishPlugin::class.java) val extension = project.extensions.create(