diff --git a/packages/sparkling-debug-tool/android/build.gradle.kts b/packages/sparkling-debug-tool/android/build.gradle.kts index 69b21800..f316d03b 100644 --- a/packages/sparkling-debug-tool/android/build.gradle.kts +++ b/packages/sparkling-debug-tool/android/build.gradle.kts @@ -34,6 +34,11 @@ android { kotlinOptions { jvmTarget = "11" } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -45,6 +50,8 @@ dependencies { implementation(libs.lynx.service.log) implementation(libs.lynx.service.devtool) implementation(libs.lynx.devtool) + testImplementation(libs.junit) + testImplementation("org.robolectric:robolectric:4.11.1") val sparklingVersion = (findProperty("SPARKLING_ANDROID_SDK_VERSION") as? String) @@ -58,9 +65,9 @@ dependencies { } val localSparklingMethod = rootProject.findProject(":sparkling-method") if (localSparklingMethod != null) { - compileOnly(localSparklingMethod) + implementation(localSparklingMethod) } else { - compileOnly("com.tiktok.sparkling:sparkling-method:$sparklingVersion") + implementation("com.tiktok.sparkling:sparkling-method:$sparklingVersion") } } diff --git a/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/DevUrlValidation.kt b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/DevUrlValidation.kt new file mode 100644 index 00000000..9762be3f --- /dev/null +++ b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/DevUrlValidation.kt @@ -0,0 +1,20 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.debugtool + +import java.net.URI + +internal const val DEV_URL_ERROR_MESSAGE = "The url must be a valid http:// or https:// URL" + +internal fun normalizedDevUrl(url: String?): String? { + val normalized = url?.trim().orEmpty() + if (normalized.isEmpty()) return null + + val parsed = runCatching { URI(normalized) }.getOrNull() ?: return null + val scheme = parsed.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return null + if (parsed.host.isNullOrEmpty()) return null + + return normalized +} diff --git a/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/SparklingDebugTool.kt b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/SparklingDebugTool.kt index 9e3d66f7..0d8bb06d 100644 --- a/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/SparklingDebugTool.kt +++ b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/SparklingDebugTool.kt @@ -32,6 +32,9 @@ import com.tiktok.sparkling.debugtool.inspect.MethodInvocation import com.tiktok.sparkling.debugtool.inspect.MethodInvocationStore import com.tiktok.sparkling.debugtool.inspect.SparklingDebugAutoWiring import com.tiktok.sparkling.debugtool.inspector.SparklingInspectorFragment +import com.tiktok.sparkling.debugtool.getDevUrl.GetDevUrlMethod +import com.tiktok.sparkling.debugtool.setDevUrl.SetDevUrlMethod +import com.tiktok.sparkling.method.registry.core.SparklingBridgeManager /** * Entry point for the Sparkling debug tool. The host typically only needs to @@ -93,6 +96,7 @@ object SparklingDebugTool { if (!isDebuggableApp(application) && !config.enableInNonDebuggableApp) { return } + registerDevUrlMethods(application) // Always apply the all-on debug flag set when running under the debug // tool: switches are no longer user-visible. Hosts that need a custom // subset can still call [setFlags] explicitly. @@ -333,6 +337,21 @@ object SparklingDebugTool { private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private fun registerDevUrlMethods(application: Application) { + SparklingBridgeManager.registerIDLMethod( + "debugtool.getDevUrl", + clazz = GetDevUrlMethod::class.java, + ) { + GetDevUrlMethod(application) + } + SparklingBridgeManager.registerIDLMethod( + "debugtool.setDevUrl", + clazz = SetDevUrlMethod::class.java, + ) { + SetDevUrlMethod(application) + } + } + private fun isDebuggableApp(context: Context): Boolean = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 private fun resolveFlags(context: Context): DebugFlags { diff --git a/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/getDevUrl/AbsGetDevUrlMethodIDL.kt b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/getDevUrl/AbsGetDevUrlMethodIDL.kt new file mode 100644 index 00000000..974e338c --- /dev/null +++ b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/getDevUrl/AbsGetDevUrlMethodIDL.kt @@ -0,0 +1,31 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.debugtool.getDevUrl + +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodName +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamField +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamModel +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodResultModel +import com.tiktok.sparkling.method.registry.core.base.AbsSparklingIDLMethod +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseParamModel +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseResultModel + +abstract class AbsGetDevUrlMethodIDL : + AbsSparklingIDLMethod< + AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlParamModel, + AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlResultModel, + >() { + @IDLMethodName(name = "debugtool.getDevUrl", results = ["url"]) + final override val name: String = "debugtool.getDevUrl" + + @IDLMethodParamModel + interface IDLMethodGetDevUrlParamModel : IDLMethodBaseParamModel + + @IDLMethodResultModel + interface IDLMethodGetDevUrlResultModel : IDLMethodBaseResultModel { + @get:IDLMethodParamField(required = true, isGetter = true, keyPath = "url") + @set:IDLMethodParamField(required = true, isGetter = false, keyPath = "url") + var url: String + } +} diff --git a/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/getDevUrl/GetDevUrlMethod.kt b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/getDevUrl/GetDevUrlMethod.kt new file mode 100644 index 00000000..e6a750fd --- /dev/null +++ b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/getDevUrl/GetDevUrlMethod.kt @@ -0,0 +1,26 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.debugtool.getDevUrl + +import android.content.Context +import com.tiktok.sparkling.debugtool.SparklingDebugTool +import com.tiktok.sparkling.method.registry.core.BridgePlatformType +import com.tiktok.sparkling.method.registry.core.model.idl.CompletionBlock +import com.tiktok.sparkling.method.registry.core.utils.createXModel + +class GetDevUrlMethod( + private val context: Context, +) : AbsGetDevUrlMethodIDL() { + override fun handle( + params: IDLMethodGetDevUrlParamModel, + callback: CompletionBlock, + type: BridgePlatformType, + ) { + callback.onSuccess( + IDLMethodGetDevUrlResultModel::class.java.createXModel(getSDKContext()?.containerID).apply { + url = SparklingDebugTool.getDevUrl(context, "") + }, + ) + } +} diff --git a/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/setDevUrl/AbsSetDevUrlMethodIDL.kt b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/setDevUrl/AbsSetDevUrlMethodIDL.kt new file mode 100644 index 00000000..b8c49f13 --- /dev/null +++ b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/setDevUrl/AbsSetDevUrlMethodIDL.kt @@ -0,0 +1,30 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.debugtool.setDevUrl + +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodName +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamField +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamModel +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodResultModel +import com.tiktok.sparkling.method.registry.core.base.AbsSparklingIDLMethod +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseParamModel +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseResultModel + +abstract class AbsSetDevUrlMethodIDL : + AbsSparklingIDLMethod< + AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlParamModel, + AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlResultModel, + >() { + @IDLMethodName(name = "debugtool.setDevUrl", params = ["url"]) + final override val name: String = "debugtool.setDevUrl" + + @IDLMethodParamModel + interface IDLMethodSetDevUrlParamModel : IDLMethodBaseParamModel { + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "url") + val url: String? + } + + @IDLMethodResultModel + interface IDLMethodSetDevUrlResultModel : IDLMethodBaseResultModel +} diff --git a/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/setDevUrl/SetDevUrlMethod.kt b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/setDevUrl/SetDevUrlMethod.kt new file mode 100644 index 00000000..f99c5014 --- /dev/null +++ b/packages/sparkling-debug-tool/android/src/main/java/com/tiktok/sparkling/debugtool/setDevUrl/SetDevUrlMethod.kt @@ -0,0 +1,32 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.debugtool.setDevUrl + +import android.content.Context +import com.tiktok.sparkling.debugtool.DEV_URL_ERROR_MESSAGE +import com.tiktok.sparkling.debugtool.SparklingDebugTool +import com.tiktok.sparkling.debugtool.normalizedDevUrl +import com.tiktok.sparkling.method.registry.core.BridgePlatformType +import com.tiktok.sparkling.method.registry.core.IDLBridgeMethod +import com.tiktok.sparkling.method.registry.core.model.idl.CompletionBlock +import com.tiktok.sparkling.method.registry.core.utils.createXModel + +class SetDevUrlMethod( + private val context: Context, +) : AbsSetDevUrlMethodIDL() { + override fun handle( + params: IDLMethodSetDevUrlParamModel, + callback: CompletionBlock, + type: BridgePlatformType, + ) { + val url = + normalizedDevUrl(params.url) + ?: return callback.onFailure(IDLBridgeMethod.INVALID_PARAM, DEV_URL_ERROR_MESSAGE) + + SparklingDebugTool.setDevUrl(context, url) + callback.onSuccess( + IDLMethodSetDevUrlResultModel::class.java.createXModel(getSDKContext()?.containerID), + ) + } +} diff --git a/packages/sparkling-debug-tool/android/src/test/java/com/tiktok/sparkling/debugtool/DebugToolDevUrlMethodTest.kt b/packages/sparkling-debug-tool/android/src/test/java/com/tiktok/sparkling/debugtool/DebugToolDevUrlMethodTest.kt new file mode 100644 index 00000000..ecef634a --- /dev/null +++ b/packages/sparkling-debug-tool/android/src/test/java/com/tiktok/sparkling/debugtool/DebugToolDevUrlMethodTest.kt @@ -0,0 +1,272 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.debugtool + +import android.app.Application +import android.content.pm.ApplicationInfo +import com.tiktok.sparkling.debugtool.getDevUrl.AbsGetDevUrlMethodIDL +import com.tiktok.sparkling.debugtool.getDevUrl.GetDevUrlMethod +import com.tiktok.sparkling.debugtool.setDevUrl.AbsSetDevUrlMethodIDL +import com.tiktok.sparkling.debugtool.setDevUrl.SetDevUrlMethod +import com.tiktok.sparkling.method.registry.core.BridgePlatformType +import com.tiktok.sparkling.method.registry.core.IDLBridgeMethod +import com.tiktok.sparkling.method.registry.core.SparklingBridgeManager +import com.tiktok.sparkling.method.registry.core.model.idl.CompletionBlock +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.lang.reflect.Proxy + +@RunWith(RobolectricTestRunner::class) +class DebugToolDevUrlMethodTest { + private lateinit var application: Application + + @Before + fun setUp() { + application = RuntimeEnvironment.getApplication() + application + .getSharedPreferences(PREFS_NAME, Application.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @After + fun tearDown() { + application + .getSharedPreferences(PREFS_NAME, Application.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun getDevUrlReturnsPersistedUrlInDataResult() { + SparklingDebugTool.setDevUrl(application, " http://127.0.0.1:5969/ ") + val callback = GetCallbackRecorder() + + GetDevUrlMethod(application).handle(emptyGetParams(), callback, BridgePlatformType.LYNX) + + assertEquals("http://127.0.0.1:5969/", callback.successResult?.url) + assertNull(callback.failureCode) + } + + @Test + fun getDevUrlUsesNestedDataResponseContract() { + SparklingDebugTool.setDevUrl(application, "https://example.com:5969/") + var response: Map? = null + + GetDevUrlMethod(application).realHandle( + emptyMap(), + object : IDLBridgeMethod.Callback { + override fun invoke(data: Map) { + response = data + } + }, + BridgePlatformType.LYNX, + ) + + assertEquals(IDLBridgeMethod.SUCCESS, response?.get(IDLBridgeMethod.PARAM_CODE)) + assertEquals( + "https://example.com:5969/", + (response?.get(IDLBridgeMethod.PARAM_DATA) as? Map<*, *>)?.get("url"), + ) + } + + @Test + fun getDevUrlReturnsEmptyStringWhenNoUrlIsPersisted() { + val callback = GetCallbackRecorder() + + GetDevUrlMethod(application).handle(emptyGetParams(), callback, BridgePlatformType.LYNX) + + assertEquals("", callback.successResult?.url) + assertNull(callback.failureCode) + } + + @Test + fun setDevUrlPersistsTrimmedHttpUrl() { + val callback = SetCallbackRecorder() + + SetDevUrlMethod(application).handle( + setParams(" https://example.com:5969/main.lynx.bundle "), + callback, + BridgePlatformType.LYNX, + ) + + assertNotNull(callback.successResult) + assertNull(callback.failureCode) + assertEquals( + "https://example.com:5969/main.lynx.bundle", + SparklingDebugTool.getDevUrl(application, ""), + ) + } + + @Test + fun setDevUrlRejectsMissingEmptyAndMalformedUrls() { + listOf( + null, + "", + " ", + "ftp://example.com/main.lynx.bundle", + "http://", + "http:///main.lynx.bundle", + "http://exa mple.com", + "not a url", + ).forEach { url -> + val callback = SetCallbackRecorder() + + SetDevUrlMethod(application).handle( + setParams(url), + callback, + BridgePlatformType.LYNX, + ) + + assertEquals(IDLBridgeMethod.INVALID_PARAM, callback.failureCode) + assertEquals(DEV_URL_ERROR_MESSAGE, callback.failureMsg) + assertEquals("", SparklingDebugTool.getDevUrl(application, "")) + } + } + + @Test + fun setDevUrlRealHandleReturnsContractCodesAndPersistsTrimmedUrl() { + val method = SetDevUrlMethod(application) + + listOf( + emptyMap(), + mapOf("url" to "not a url"), + ).forEach { params -> + var response: Map? = null + + method.realHandle( + params, + object : IDLBridgeMethod.Callback { + override fun invoke(data: Map) { + response = data + } + }, + BridgePlatformType.LYNX, + ) + + assertEquals(IDLBridgeMethod.INVALID_PARAM, response?.get(IDLBridgeMethod.PARAM_CODE)) + assertEquals(DEV_URL_ERROR_MESSAGE, response?.get(IDLBridgeMethod.PARAM_MSG)) + assertEquals("", SparklingDebugTool.getDevUrl(application, "")) + } + + var response: Map? = null + method.realHandle( + mapOf("url" to " https://example.com:5969/main.lynx.bundle "), + object : IDLBridgeMethod.Callback { + override fun invoke(data: Map) { + response = data + } + }, + BridgePlatformType.LYNX, + ) + + assertEquals(IDLBridgeMethod.SUCCESS, response?.get(IDLBridgeMethod.PARAM_CODE)) + assertEquals( + "https://example.com:5969/main.lynx.bundle", + SparklingDebugTool.getDevUrl(application, ""), + ) + } + + @Test + fun initRegistersBothMethodsIdempotently() { + application.applicationInfo.flags = + application.applicationInfo.flags or ApplicationInfo.FLAG_DEBUGGABLE + + SparklingDebugTool.init(application) + SparklingDebugTool.init(application) + + assertEquals( + GetDevUrlMethod::class.java, + SparklingBridgeManager.findIDLMethodClass( + BridgePlatformType.LYNX, + "debugtool.getDevUrl", + ), + ) + assertEquals( + SetDevUrlMethod::class.java, + SparklingBridgeManager.findIDLMethodClass( + BridgePlatformType.LYNX, + "debugtool.setDevUrl", + ), + ) + } + + @Suppress("UNCHECKED_CAST") + private fun emptyGetParams(): AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlParamModel = + Proxy.newProxyInstance( + javaClass.classLoader, + arrayOf(AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlParamModel::class.java), + ) { _, _, _ -> null } as AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlParamModel + + @Suppress("UNCHECKED_CAST") + private fun setParams(url: String?): AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlParamModel = + Proxy.newProxyInstance( + javaClass.classLoader, + arrayOf(AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlParamModel::class.java), + ) { _, method, _ -> + when (method.name) { + "getUrl" -> url + else -> null + } + } as AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlParamModel + + private class GetCallbackRecorder : CompletionBlock { + var successResult: AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlResultModel? = null + var failureCode: Int? = null + + override fun onSuccess( + result: AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlResultModel, + msg: String, + ) { + successResult = result + } + + override fun onFailure( + code: Int, + msg: String, + data: AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlResultModel?, + ) { + failureCode = code + } + + override fun onRawSuccess(data: AbsGetDevUrlMethodIDL.IDLMethodGetDevUrlResultModel?) = Unit + } + + private class SetCallbackRecorder : CompletionBlock { + var successResult: AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlResultModel? = null + var failureCode: Int? = null + var failureMsg: String? = null + + override fun onSuccess( + result: AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlResultModel, + msg: String, + ) { + successResult = result + } + + override fun onFailure( + code: Int, + msg: String, + data: AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlResultModel?, + ) { + failureCode = code + failureMsg = msg + } + + override fun onRawSuccess(data: AbsSetDevUrlMethodIDL.IDLMethodSetDevUrlResultModel?) = Unit + } + + private companion object { + const val PREFS_NAME = "sparkling_debug_tool" + } +} diff --git a/packages/sparkling-debug-tool/ios/Sources/Methods/DevURLMethods.swift b/packages/sparkling-debug-tool/ios/Sources/Methods/DevURLMethods.swift new file mode 100644 index 00000000..208f7e95 --- /dev/null +++ b/packages/sparkling-debug-tool/ios/Sources/Methods/DevURLMethods.swift @@ -0,0 +1,123 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +import SparklingMethod + +private let devURLErrorMessage = "The url must be a valid http:// or https:// URL" + +private func normalizedDevURL(_ value: String?) -> String? { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard + !normalized.isEmpty, + let components = URLComponents(string: normalized), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = components.host, + !host.isEmpty + else { + return nil + } + return normalized +} + +@objc(GetDevUrlMethod) +public final class GetDevUrlMethod: PipeMethod { + public override var methodName: String { + Self.methodName() + } + + public override class func methodName() -> String { + "debugtool.getDevUrl" + } + + public override var paramsModelClass: AnyClass { + EmptyMethodModelClass.self + } + + public override var resultModelClass: AnyClass { + GetDevUrlMethodResultModel.self + } + + public override func call( + withParamModel paramModel: Any, + completionHandler: CompletionHandlerProtocol + ) { + guard paramModel is EmptyMethodModelClass else { + completionHandler.handleCompletion( + status: .invalidParameter(message: "Invalid parameter model type"), + result: nil + ) + return + } + + let result = GetDevUrlMethodResultModel() + result.url = SparklingDebugTool.devURL(fallback: "") + completionHandler.handleCompletion(status: .succeeded(), result: result) + } +} + +@objc(GetDevUrlMethodResultModel) +public final class GetDevUrlMethodResultModel: SPKMethodModel { + @objc public var url: String? + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + [ + "url": "url" + ] + } +} + +@objc(SetDevUrlMethod) +public final class SetDevUrlMethod: PipeMethod { + public override var methodName: String { + Self.methodName() + } + + public override class func methodName() -> String { + "debugtool.setDevUrl" + } + + public override var paramsModelClass: AnyClass { + SetDevUrlMethodParamModel.self + } + + public override var resultModelClass: AnyClass { + EmptyMethodModelClass.self + } + + public override func call( + withParamModel paramModel: Any, + completionHandler: CompletionHandlerProtocol + ) { + guard let params = paramModel as? SetDevUrlMethodParamModel else { + completionHandler.handleCompletion( + status: .invalidParameter(message: "Invalid parameter model type"), + result: nil + ) + return + } + guard let url = normalizedDevURL(params.url) else { + completionHandler.handleCompletion( + status: .invalidParameter(message: devURLErrorMessage), + result: nil + ) + return + } + + SparklingDebugTool.setDevURL(url) + completionHandler.handleCompletion(status: .succeeded(), result: nil) + } +} + +@objc(SetDevUrlMethodParamModel) +public final class SetDevUrlMethodParamModel: SPKMethodModel { + @objc public var url: String? + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + [ + "url": "url" + ] + } +} diff --git a/packages/sparkling-debug-tool/ios/Sparkling-DebugTool.podspec b/packages/sparkling-debug-tool/ios/Sparkling-DebugTool.podspec index 54bcebde..306c7134 100644 --- a/packages/sparkling-debug-tool/ios/Sparkling-DebugTool.podspec +++ b/packages/sparkling-debug-tool/ios/Sparkling-DebugTool.podspec @@ -15,6 +15,13 @@ Pod::Spec.new do |s| 'Sources/**/*.{h,m,swift}' ] + s.test_spec 'Tests' do |tests| + tests.requires_app_host = false + tests.source_files = [ + 'SparklingMethodTests/**/*.{h,m,swift}' + ] + end + s.resource_bundles = { 'SparklingDebugToolAssets' => [ 'packages/sparkling-debug-tool/ios/Resources/**/*.{png,xcassets,json}', @@ -27,4 +34,5 @@ Pod::Spec.new do |s| s.dependency 'LynxService/Devtool', '~> 3.9.0' s.dependency 'LynxDevtool/Framework', '~> 3.9.0' s.dependency 'DebugRouter' + s.dependency 'SparklingMethod/Core' end diff --git a/packages/sparkling-debug-tool/ios/SparklingMethodTests/SparklingDebugToolDevURLMethodTests.swift b/packages/sparkling-debug-tool/ios/SparklingMethodTests/SparklingDebugToolDevURLMethodTests.swift new file mode 100644 index 00000000..b5c494b7 --- /dev/null +++ b/packages/sparkling-debug-tool/ios/SparklingMethodTests/SparklingDebugToolDevURLMethodTests.swift @@ -0,0 +1,131 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import SparklingMethod +import XCTest +@testable import Sparkling_DebugTool + +private final class DebugToolCompletionRecorder: NSObject, PipeMethod.CompletionHandlerProtocol { + var status: MethodStatus? + var result: SPKMethodModel? + + func handleCompletion(status: MethodStatus, result: SPKMethodModel?) { + self.status = status + self.result = result + } +} + +final class SparklingDebugToolDevURLMethodTests: XCTestCase { + override func setUp() { + super.setUp() + SparklingDebugTool.setDevURL("") + } + + override func tearDown() { + SparklingDebugTool.setDevURL("") + super.tearDown() + } + + func testMethodNamesAndModels() { + XCTAssertEqual(GetDevUrlMethod.methodName(), "debugtool.getDevUrl") + XCTAssertEqual(GetDevUrlMethod().methodName, "debugtool.getDevUrl") + XCTAssertTrue(GetDevUrlMethod().paramsModelClass is EmptyMethodModelClass.Type) + XCTAssertTrue(GetDevUrlMethod().resultModelClass is GetDevUrlMethodResultModel.Type) + + XCTAssertEqual(SetDevUrlMethod.methodName(), "debugtool.setDevUrl") + XCTAssertEqual(SetDevUrlMethod().methodName, "debugtool.setDevUrl") + XCTAssertTrue(SetDevUrlMethod().paramsModelClass is SetDevUrlMethodParamModel.Type) + XCTAssertTrue(SetDevUrlMethod().resultModelClass is EmptyMethodModelClass.Type) + } + + func testGetDevUrlReturnsPersistedUrlInResultModel() throws { + SparklingDebugTool.setDevURL(" http://127.0.0.1:5969/ ") + let recorder = DebugToolCompletionRecorder() + + GetDevUrlMethod().call( + withParamModel: EmptyMethodModelClass(), + completionHandler: recorder + ) + + XCTAssertEqual(recorder.status?.code, .succeeded) + let result = try XCTUnwrap(recorder.result as? GetDevUrlMethodResultModel) + XCTAssertEqual(result.url, "http://127.0.0.1:5969/") + XCTAssertEqual(try result.toDict()?["url"] as? String, "http://127.0.0.1:5969/") + } + + func testGetDevUrlReturnsEmptyStringWhenNoUrlIsPersisted() { + let recorder = DebugToolCompletionRecorder() + + GetDevUrlMethod().call( + withParamModel: EmptyMethodModelClass(), + completionHandler: recorder + ) + + XCTAssertEqual(recorder.status?.code, .succeeded) + XCTAssertEqual( + (recorder.result as? GetDevUrlMethodResultModel)?.url, + "" + ) + } + + func testSetDevUrlPersistsTrimmedHttpUrl() throws { + let params = try SetDevUrlMethodParamModel( + dictionary: ["url": " https://example.com:5969/main.lynx.bundle "] + ) + let recorder = DebugToolCompletionRecorder() + + SetDevUrlMethod().call(withParamModel: params, completionHandler: recorder) + + XCTAssertEqual(recorder.status?.code, .succeeded) + XCTAssertNil(recorder.result) + XCTAssertEqual( + SparklingDebugTool.devURL(fallback: ""), + "https://example.com:5969/main.lynx.bundle" + ) + } + + func testSetDevUrlRejectsMissingEmptyAndMalformedUrls() throws { + let values: [String?] = [ + nil, + "", + " ", + "ftp://example.com/main.lynx.bundle", + "http://", + "http:///main.lynx.bundle", + "http://exa mple.com", + "not a url", + ] + for url in values { + let params = try SetDevUrlMethodParamModel(dictionary: url.map { ["url": $0] } ?? [:]) + let recorder = DebugToolCompletionRecorder() + + SetDevUrlMethod().call(withParamModel: params, completionHandler: recorder) + + XCTAssertEqual(recorder.status?.code, .invalidInputParameter) + XCTAssertEqual( + recorder.status?.message, + "The url must be a valid http:// or https:// URL" + ) + XCTAssertEqual(SparklingDebugTool.devURL(fallback: ""), "") + } + } + + func testMethodsAreDiscoveredByGlobalAutoRegistration() { + MethodRegistry.global.unregister(methodName: GetDevUrlMethod.methodName()) + MethodRegistry.global.unregister(methodName: SetDevUrlMethod.methodName()) + defer { + MethodRegistry.global.unregister(methodName: GetDevUrlMethod.methodName()) + MethodRegistry.global.unregister(methodName: SetDevUrlMethod.methodName()) + } + + MethodRegistry.autoRegisterGlobalMethods() + + XCTAssertTrue( + MethodRegistry.global.respondTo(methodName: GetDevUrlMethod.methodName()) + ) + XCTAssertTrue( + MethodRegistry.global.respondTo(methodName: SetDevUrlMethod.methodName()) + ) + } +}