diff --git a/.gitignore b/.gitignore index ea1e7396..99600a4f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ *.podspec node_modules js-chat/dist +test.properties ### IntelliJ IDEA ### .idea/modules.xml diff --git a/build-logic/gradle-plugins/src/main/kotlin/com/pubnub/gradle/PubNubSharedPlugin.kt b/build-logic/gradle-plugins/src/main/kotlin/com/pubnub/gradle/PubNubSharedPlugin.kt index 92c6388d..7e18c47e 100644 --- a/build-logic/gradle-plugins/src/main/kotlin/com/pubnub/gradle/PubNubSharedPlugin.kt +++ b/build-logic/gradle-plugins/src/main/kotlin/com/pubnub/gradle/PubNubSharedPlugin.kt @@ -16,16 +16,17 @@ import org.jlleitschuh.gradle.ktlint.KtlintPlugin class PubNubSharedPlugin : Plugin { override fun apply(target: Project) { with(target) { - apply() - apply() - - extensions.configure { - repositories { - it.maven(uri(rootProject.layout.buildDirectory.dir("repo"))) { -> - name = "repo" + if (!target.name.endsWith("-test")) { + apply() + extensions.configure { + repositories { + it.maven(uri(rootProject.layout.buildDirectory.dir("repo"))) { -> + name = "repo" + } } } } + apply() group = providers.gradleProperty("GROUP").get() version = providers.gradleProperty("VERSION_NAME").get() diff --git a/build.gradle.kts b/build.gradle.kts index c4fa1b7e..10633c3e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,5 @@ import com.pubnub.gradle.enableAnyIosTarget import com.pubnub.gradle.enableJsTarget -import com.pubnub.gradle.tasks.GenerateVersionTask -import org.jetbrains.kotlin.gradle.dsl.JsModuleKind import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension plugins { @@ -62,7 +60,7 @@ kotlin { val commonTest by getting { dependencies { implementation(kotlin("test")) - implementation(libs.pubnub.kotlin.test) + implementation(project(":pubnub-chat-test")) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59245475..0d33b854 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,16 @@ [versions] nexus = "2.0.0" -kotlin = "2.1.0" +kotlin = "2.0.21" vanniktech = "0.29.0" ktlint = "12.1.0" dokka = "1.9.20" -kotlinx_serialization = "1.7.1" +kotlinx_serialization = "1.7.3" +kotlinx_coroutines = "1.9.0" pubnub = "10.2.1-dev" [libraries] pubnub-kotlin-api = { module = "com.pubnub:pubnub-kotlin-api", version.ref = "pubnub" } pubnub-kotlin = { module = "com.pubnub:pubnub-kotlin", version.ref = "pubnub" } -pubnub-kotlin-test = { module = "com.pubnub:pubnub-kotlin-test", version.ref = "pubnub" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version = "0.25.0" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx_serialization" } touchlab-kermit = { module = "co.touchlab:kermit", version = "2.0.4" } @@ -19,8 +19,7 @@ touchlab-kermit = { module = "co.touchlab:kermit", version = "2.0.4" } #logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } #logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logback" } #junit4 = { module = "junit:junit", version = "4.13.2" } -#coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx_coroutines"} -# +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx_coroutines"} # plugins for included build kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } @@ -40,4 +39,5 @@ vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = gradle-nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus" } kotlinx-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.2" } mokkery = { id = "dev.mokkery", version = "2.4.0" } -npm-publish = { id = "dev.petuska.npm.publish", version = "3.4.3" } \ No newline at end of file +npm-publish = { id = "dev.petuska.npm.publish", version = "3.4.3" } +codingfeline-buildkonfig = { id = "com.codingfeline.buildkonfig", version = "0.15.1" } \ No newline at end of file diff --git a/pubnub-chat-impl/build.gradle.kts b/pubnub-chat-impl/build.gradle.kts index 788f535f..9fb05666 100644 --- a/pubnub-chat-impl/build.gradle.kts +++ b/pubnub-chat-impl/build.gradle.kts @@ -33,7 +33,7 @@ kotlin { val commonTest by getting { dependencies { implementation(kotlin("test")) - implementation(libs.pubnub.kotlin.test) + implementation(project(":pubnub-chat-test")) } } @@ -74,4 +74,4 @@ val generateVersion = ) } -kotlin.sourceSets.getByName("commonMain").kotlin.srcDir(generateVersion) \ No newline at end of file +kotlin.sourceSets.getByName("commonMain").kotlin.srcDir(generateVersion) diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt index 68c172ec..db516485 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt @@ -314,7 +314,7 @@ class ChannelIntegrationTest : BaseChatIntegrationTest() { } } // T = 0s: User1 starts typing - println("-=T ${Clock.System.now()} = 0s: user: ${channel01.chat.currentUser.id} starts typing") +// println("-=T ${Clock.System.now()} = 0s: user: ${channel01.chat.currentUser.id} starts typing") channel01.startTyping().await() delayInMillis(1000) diff --git a/pubnub-chat-test/build.gradle.kts b/pubnub-chat-test/build.gradle.kts new file mode 100644 index 00000000..740bd281 --- /dev/null +++ b/pubnub-chat-test/build.gradle.kts @@ -0,0 +1,69 @@ + +import com.codingfeline.buildkonfig.compiler.FieldSpec.Type +import java.util.Properties + +plugins { + alias(libs.plugins.benmanes.versions) + id("pubnub.shared") + id("pubnub.ios-simulator-test") + id("pubnub.base.multiplatform") + alias(libs.plugins.codingfeline.buildkonfig) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(project(":pubnub-chat-api")) + api(kotlin("test")) + api(libs.coroutines.test) + } + } + + val jvmMain by getting { + dependencies { + api(kotlin("test-junit")) + } + } + } + + ktlint { + filter { + exclude { it: FileTreeElement -> it.file.absolutePath.also { println(it) }.contains("/build/") } + } + } + + buildkonfig { + packageName = "com.pubnub.test" + exposeObjectWithName = "Keys" + + defaultConfigs { + val testProps = Properties() + try { + val bytes = providers.fileContents(rootProject.layout.projectDirectory.file("test.properties")).asBytes.get() + testProps.load(bytes.inputStream()) + } catch (e: Exception) { + println("No test.properties found in root project. Trying to get keys from env") + try { + testProps.setProperty("pubKey", providers.environmentVariable("SDK_PUB_KEY").get()) + testProps.setProperty("subKey", providers.environmentVariable("SDK_SUB_KEY").get()) + testProps.setProperty("pamPubKey", providers.environmentVariable("SDK_PAM_PUB_KEY").get()) + testProps.setProperty("pamSubKey", providers.environmentVariable("SDK_PAM_SUB_KEY").get()) + testProps.setProperty("pamSecKey", providers.environmentVariable("SDK_PAM_SEC_KEY").get()) + } catch (e: IllegalStateException) { + println("No env variables found. Setting all keys to demo") + testProps.setProperty("pubKey", "demo") + testProps.setProperty("subKey", "demo") + testProps.setProperty("pamPubKey", "demo") + testProps.setProperty("pamSubKey", "demo") + testProps.setProperty("pamSecKey", "demo") + } + } + buildConfigField(Type.STRING, "pubKey", testProps.getProperty("pubKey")) + buildConfigField(Type.STRING, "subKey", testProps.getProperty("subKey")) + buildConfigField(Type.STRING, "pamPubKey", testProps.getProperty("pamPubKey")) + buildConfigField(Type.STRING, "pamSubKey", testProps.getProperty("pamSubKey")) + buildConfigField(Type.STRING, "pamSecKey", testProps.getProperty("pamSecKey")) + } + } +} diff --git a/pubnub-chat-test/config/ktlint/baseline.xml b/pubnub-chat-test/config/ktlint/baseline.xml new file mode 100644 index 00000000..98142077 --- /dev/null +++ b/pubnub-chat-test/config/ktlint/baseline.xml @@ -0,0 +1,3 @@ + + + diff --git a/pubnub-chat-test/src/appleMain/kotlin/testlauncher.kt b/pubnub-chat-test/src/appleMain/kotlin/testlauncher.kt new file mode 100644 index 00000000..b32fe166 --- /dev/null +++ b/pubnub-chat-test/src/appleMain/kotlin/testlauncher.kt @@ -0,0 +1,20 @@ +package testlauncher + +import platform.CoreFoundation.CFRunLoopRun +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.concurrent.TransferMode +import kotlin.native.concurrent.Worker +import kotlin.native.concurrent.freeze +import kotlin.native.internal.test.testLauncherEntryPoint +import kotlin.system.exitProcess + +@OptIn(ExperimentalNativeApi::class) +fun mainBackground(args: Array) { + val worker = Worker.start(name = "main-background") + worker.execute(TransferMode.SAFE, { args.freeze() }) { + val result = testLauncherEntryPoint(it) + exitProcess(result) + } + CFRunLoopRun() + error("CFRunLoopRun should never return") +} diff --git a/pubnub-chat-test/src/commonMain/kotlin/com.pubnub.test/BaseIntegrationTest.kt b/pubnub-chat-test/src/commonMain/kotlin/com.pubnub.test/BaseIntegrationTest.kt new file mode 100644 index 00000000..0c72d0b0 --- /dev/null +++ b/pubnub-chat-test/src/commonMain/kotlin/com.pubnub.test/BaseIntegrationTest.kt @@ -0,0 +1,318 @@ +package com.pubnub.test + +import com.pubnub.api.PubNub +import com.pubnub.api.UserId +import com.pubnub.api.enums.PNLogVerbosity +import com.pubnub.api.enums.PNStatusCategory +import com.pubnub.api.models.consumer.PNStatus +import com.pubnub.api.models.consumer.pubsub.PNEvent +import com.pubnub.api.models.consumer.pubsub.PNMessageResult +import com.pubnub.api.v2.PNConfiguration +import com.pubnub.api.v2.createPNConfiguration +import com.pubnub.api.v2.subscriptions.EmptyOptions +import com.pubnub.api.v2.subscriptions.SubscriptionOptions +import com.pubnub.kmp.PNFuture +import com.pubnub.kmp.createEventListener +import com.pubnub.kmp.createPubNub +import com.pubnub.kmp.createStatusListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +abstract class BaseIntegrationTest { + lateinit var config: PNConfiguration + lateinit var config02: PNConfiguration + lateinit var configPamServer: PNConfiguration + lateinit var configPamClient: PNConfiguration + lateinit var pubnub: PubNub + lateinit var pubnub02: PubNub + lateinit var pubnubPamServer: PubNub + lateinit var pubnubPamClient: PubNub + + @BeforeTest + open fun before() { + config = createPNConfiguration( + UserId(randomString()), + Keys.subKey, + Keys.pubKey, + logVerbosity = PNLogVerbosity.BODY, + authToken = null + ) + config02 = createPNConfiguration( + UserId(randomString()), + Keys.subKey, + Keys.pubKey, + logVerbosity = PNLogVerbosity.BODY, + authToken = null + ) + pubnub = createPubNub(config) + pubnub02 = createPubNub(config02) + configPamServer = createPNConfiguration( + UserId(randomString()), + Keys.pamSubKey, + Keys.pamPubKey, + Keys.pamSecKey, + PNLogVerbosity.BODY + ) + configPamClient = createPNConfiguration( + UserId(randomString()), + Keys.pamSubKey, + Keys.pamPubKey, + logVerbosity = PNLogVerbosity.BODY, + authToken = null + ) + pubnubPamServer = createPubNub(configPamServer) + pubnubPamClient = createPubNub(configPamClient) + } + + @AfterTest + open fun after() { + pubnub.unsubscribeAll() + pubnub.destroy() + pubnub02.unsubscribeAll() + pubnub02.destroy() + pubnubPamServer.unsubscribeAll() + pubnubPamServer.destroy() + pubnubPamClient.unsubscribeAll() + pubnubPamClient.destroy() + } +} + +suspend fun PNFuture.await(): T { + val t = suspendCancellableCoroutine { cont -> + async { result -> + result.onSuccess { + cont.resume(it) + }.onFailure { + cont.resumeWithException(it) + } + } + } + withContext(Dispatchers.Default) { + delay(100) + } + return t +} + +class PubNubTest( + private val pubNub: PubNub, + private val withPresenceOverride: Boolean, + backgroundScope: CoroutineScope, + private val checkAllEvents: Boolean = true, +) { + private val messageQueue = Channel(10) + private val statusQueue = Channel(10) + + private val statusVerificationListener = createStatusListener(pubNub) { _, status -> + backgroundScope.launch { + statusQueue.send(status) + } + } + private val eventVerificationListener = createEventListener( + pubNub, + onMessage = { _, event -> + backgroundScope.launch { + messageQueue.send(event) + } + }, + onSignal = { _, event -> + backgroundScope.launch { + messageQueue.send(event) + } + }, + onFile = { _, event -> + backgroundScope.launch { + messageQueue.send(event) + } + }, + onPresence = { _, event -> + backgroundScope.launch { + messageQueue.send(event) + } + }, + onObjects = { _, event -> + backgroundScope.launch { + messageQueue.send(event) + } + }, + onMessageAction = { _, event -> + backgroundScope.launch { + messageQueue.send(event) + } + }, + ) + + init { + pubNub.addListener(eventVerificationListener) + pubNub.addListener(statusVerificationListener) + } + + suspend fun com.pubnub.api.v2.entities.Channel.awaitSubscribe(options: SubscriptionOptions = EmptyOptions) = suspendCancellableCoroutine { cont -> + val subscription = subscription(options) + val statusListener = createStatusListener(pubNub) { _, pnStatus -> + if ((pnStatus.category == PNStatusCategory.PNConnectedCategory || pnStatus.category == PNStatusCategory.PNSubscriptionChanged) && + pnStatus.affectedChannels.contains(name) + ) { + cont.resume(subscription) + } + if (pnStatus.category == PNStatusCategory.PNUnexpectedDisconnectCategory || pnStatus.category == PNStatusCategory.PNConnectionError) { + cont.resumeWithException(pnStatus.exception ?: RuntimeException(pnStatus.category.toString())) + } + } + pubNub.addListener(statusListener) + cont.invokeOnCancellation { + pubNub.removeListener(statusListener) + } + subscription.subscribe() + } + + suspend fun PubNub.awaitSubscribe( + channels: Collection = setOf(), + channelGroups: Collection = setOf(), + withPresence: Boolean = false, + customSubscriptionBlock: () -> Unit = { + subscribe(channels.toList(), channelGroups.toList(), withPresence) + } + ) = suspendCancellableCoroutine { cont -> + val statusListener = createStatusListener(pubNub) { _, pnStatus -> + if ((pnStatus.category == PNStatusCategory.PNConnectedCategory || pnStatus.category == PNStatusCategory.PNSubscriptionChanged) && + pnStatus.affectedChannels.containsAll(channels) && pnStatus.affectedChannelGroups.containsAll( + channelGroups + ) || ( + getSubscribedChannels().containsAll(channels) && getSubscribedChannelGroups().containsAll( + channelGroups + ) + ) + ) { + if (!cont.isCompleted) { + cont.resume(Unit) + } + } else if (pnStatus.category == PNStatusCategory.PNUnexpectedDisconnectCategory || pnStatus.category == PNStatusCategory.PNConnectionError) { + cont.resumeWithException(pnStatus.exception ?: RuntimeException(pnStatus.category.toString())) + } + } + pubNub.addListener(statusListener) + cont.invokeOnCancellation { + pubNub.removeListener(statusListener) + } + customSubscriptionBlock() + } + + suspend fun PubNub.awaitUnsubscribe( + channels: Collection = setOf(), + channelGroups: Collection = setOf(), + customUnsubscribeBlock: () -> Unit = { + unsubscribe(channels.toList(), channelGroups.toList()) + } + ) { + coroutineScope { + val job = launch { + suspendCancellableCoroutine { cont -> + val statusListener = createStatusListener(pubNub) { _, pnStatus -> + if (cont.isCompleted) { + return@createStatusListener + } + if ((pnStatus.category == PNStatusCategory.PNDisconnectedCategory || pnStatus.category == PNStatusCategory.PNSubscriptionChanged) && + ( + pnStatus.affectedChannels.containsAll(channels) && pnStatus.affectedChannelGroups.containsAll( + channelGroups + ) + ) + ) { + cont.resume(Unit) + } + if (pnStatus.category == PNStatusCategory.PNUnexpectedDisconnectCategory || pnStatus.category == PNStatusCategory.PNConnectionError) { + cont.resumeWithException(pnStatus.exception ?: RuntimeException(pnStatus.category.toString())) + } + } + pubNub.addListener(statusListener) + cont.invokeOnCancellation { + pubNub.removeListener(statusListener) + } + customUnsubscribeBlock() + } + } + withContext(Dispatchers.Default) { + delay(500.milliseconds) + } + job.cancelAndJoin() + if (getSubscribedChannels().any { it in channels } || getSubscribedChannelGroups().any { it in channelGroups }) { + error("Didn't unsubscribe on time") + } + } + } + +// fun unsubscribeAll() { +// pubNub.unsubscribeAll() +// val status = statusQueue.take() +// Assert.assertTrue(status.category == PNStatusCategory.PNDisconnectedCategory) +// } + + suspend fun nextStatus(): PNStatus = statusQueue.receive() + +// suspend fun nextStatus(timeout: Duration): PNStatus? { +// return try { +// FutureTask { +// statusQueue.take() +// }.get(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) +// } catch (e: TimeoutException) { +// null +// } +// } + + suspend fun nextEvent(): T { + return messageQueue.receive() as T + } + + suspend fun nextMessage() = nextEvent() + + suspend fun skip(n: Int = 1) { + for (i in 0 until n) { + nextEvent() + } + } + + fun close() { + pubNub.unsubscribeAll() + pubNub.destroy() + + val remainingMessages = buildList { + messageQueue.tryReceive().getOrNull()?.apply { add(this) } ?: return@buildList + } + if (checkAllEvents) { + assertTrue( + "There were ${remainingMessages.size} unverified events in the test: ${remainingMessages.joinToString(", ")}" + ) { remainingMessages.isEmpty() } + } + } +} + +suspend fun PubNub.test( + backgroundScope: CoroutineScope, + withPresence: Boolean = false, + checkAllEvents: Boolean = true, + action: suspend PubNubTest.() -> Unit, +) { + val pubNubTest = PubNubTest(this, withPresence, backgroundScope, checkAllEvents) + try { + with(pubNubTest) { + action() + } + } finally { + pubNubTest.close() + } +} + +fun randomString() = (0..6).map { "abcdefghijklmnopqrstuvw".random() }.joinToString("") diff --git a/settings.gradle.kts b/settings.gradle.kts index 5d0443bc..cde62604 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ dependencyResolutionManagement { includeBuild("build-logic/ktlint-custom-rules") -include(":pubnub-chat-api") -include(":pubnub-chat-impl") +include("pubnub-chat-api") +include("pubnub-chat-impl") +include("pubnub-chat-test") include("pubnub-3p-diff-match-patch")