diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0e5d3af..992d818 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -62,6 +62,8 @@ jobs: run: > ./gradlew :agent-sdk:testDebugUnitTest + :app-sdk:testDebugUnitTest + :mock-aura-data:testDebugUnitTest :mock-harmony-mesh:testDebugUnitTest :mock-self-wallet:testDebugUnitTest :mock-app-store:testDebugUnitTest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcf9c7f..61421b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,8 @@ jobs: run: > ./gradlew :agent-sdk:testDebugUnitTest + :app-sdk:testDebugUnitTest + :mock-aura-data:testDebugUnitTest :mock-harmony-mesh:testDebugUnitTest :mock-self-wallet:testDebugUnitTest :mock-app-store:testDebugUnitTest diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ea840..1d1370c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,34 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +## [0.1.2-community] - 2026-07-02 + +### Added + +- Unit tests for **DefaultPluginRegistry** (`app-sdk`) and **MockAuraDataClient** (`mock-aura-data`). +- **DefaultAgentRuntime** test coverage for agents that throw during `sendRequest`. +- Shared **UI test tags** (`UiTestTags`) on Android and iOS home screen, welcome text, and home tab. +- **Settings** screen shows app version (Android `BuildConfig`, iOS bundle short version). +- CI runs `:app-sdk:testDebugUnitTest` and `:mock-aura-data:testDebugUnitTest`. + +### Changed + +- Launch smoke tests assert stable `home_screen` / `home_welcome` identifiers instead of brittle text matching. +- Android `versionName` **0.1.2-community** (`versionCode` 2); iOS `MARKETING_VERSION` **0.1.2**. + +### Fixed + +- **DefaultPluginRegistry** stops the previous app when re-registering the same id. + +## [0.1.1-community] - 2026-07-02 + ### Added - Android **launch smoke test** (`LaunchSmokeTest`) and iOS **UI launch test** (`SelfOSPIUITests`). - GitHub Release workflow now attaches **Android APKs** (debug + release R8 builds). - [DEVELOPMENT.md](DEVELOPMENT.md) — local build, test, and first-agent contributor guide. - [SECURITY.md](SECURITY.md) — private vulnerability reporting and disclosure timelines. +- Optional **android-smoke** CI job (emulator launch test; non-blocking). ## [0.1.0-community] - 2026-07-02 @@ -31,4 +53,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version - `scripts/scan-public-safety` blocks risky filenames and patterns before release. +[Unreleased]: https://github.com/selflabs/SELF-OS/compare/v0.1.2-community...HEAD +[0.1.2-community]: https://github.com/selflabs/SELF-OS/compare/v0.1.1-community...v0.1.2-community +[0.1.1-community]: https://github.com/selflabs/SELF-OS/compare/v0.1.0-community...v0.1.1-community [0.1.0-community]: https://github.com/selflabs/SELF-OS/releases/tag/v0.1.0-community diff --git a/apps/self-shell-ios/SelfOSPI.xcodeproj/project.pbxproj b/apps/self-shell-ios/SelfOSPI.xcodeproj/project.pbxproj index 97c0336..8e4f909 100644 --- a/apps/self-shell-ios/SelfOSPI.xcodeproj/project.pbxproj +++ b/apps/self-shell-ios/SelfOSPI.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ A1000001000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A2000001000000000000000F /* Assets.xcassets */; }; A10000010000000000000010 /* SelfOSPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000010 /* SelfOSPITests.swift */; }; A10000010000000000000011 /* SelfOSPIUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000011 /* SelfOSPIUITests.swift */; }; + A10000010000000000000012 /* UiTestTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010000000000000012 /* UiTestTags.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -47,6 +48,7 @@ A30000010000000000000003 /* SelfOSPIUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfOSPIUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A20000010000000000000010 /* SelfOSPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfOSPITests.swift; sourceTree = ""; }; A20000010000000000000011 /* SelfOSPIUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfOSPIUITests.swift; sourceTree = ""; }; + A20000010000000000000012 /* UiTestTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UiTestTags.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -89,6 +91,7 @@ children = ( A20000010000000000000001 /* SelfOSPIApp.swift */, A20000010000000000000002 /* ContentView.swift */, + A20000010000000000000012 /* UiTestTags.swift */, A20000010000000000000003 /* Models.swift */, A50000010000000000000004 /* Agents */, A50000010000000000000005 /* Services */, @@ -346,6 +349,7 @@ buildActionMask = 2147483647; files = ( A10000010000000000000001 /* SelfOSPIApp.swift in Sources */, + A10000010000000000000012 /* UiTestTags.swift in Sources */, A10000010000000000000002 /* ContentView.swift in Sources */, A10000010000000000000003 /* Models.swift in Sources */, A10000010000000000000004 /* ExampleAgents.swift in Sources */, @@ -428,7 +432,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = ai.selflabs.selfospi; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -457,7 +461,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = ai.selflabs.selfospi; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -480,7 +484,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = ai.selflabs.selfospi.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -504,7 +508,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = ai.selflabs.selfospi.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -527,7 +531,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = ai.selflabs.selfospi.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -550,7 +554,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = ai.selflabs.selfospi.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/apps/self-shell-ios/SelfOSPI/ContentView.swift b/apps/self-shell-ios/SelfOSPI/ContentView.swift index 26205de..e89b398 100644 --- a/apps/self-shell-ios/SelfOSPI/ContentView.swift +++ b/apps/self-shell-ios/SelfOSPI/ContentView.swift @@ -8,6 +8,7 @@ struct ContentView: View { TabView { HomeView() .tabItem { Label("Home", systemImage: "house") } + .accessibilityIdentifier(UiTestTags.tabHome) AgentsView() .tabItem { Label("Agents", systemImage: "cpu") } AppsView() diff --git a/apps/self-shell-ios/SelfOSPI/UiTestTags.swift b/apps/self-shell-ios/SelfOSPI/UiTestTags.swift new file mode 100644 index 0000000..331ddaa --- /dev/null +++ b/apps/self-shell-ios/SelfOSPI/UiTestTags.swift @@ -0,0 +1,5 @@ +enum UiTestTags { + static let homeScreen = "home_screen" + static let homeWelcome = "home_welcome" + static let tabHome = "tab_home" +} diff --git a/apps/self-shell-ios/SelfOSPI/Views/HomeView.swift b/apps/self-shell-ios/SelfOSPI/Views/HomeView.swift index c3216f2..bcad6c4 100644 --- a/apps/self-shell-ios/SelfOSPI/Views/HomeView.swift +++ b/apps/self-shell-ios/SelfOSPI/Views/HomeView.swift @@ -10,6 +10,7 @@ struct HomeView: View { Text("SELF OS Personal Intelligence (Community Edition)") .font(.title2.bold()) Text("Welcome to your local personal intelligence workspace.") + .accessibilityIdentifier(UiTestTags.homeWelcome) MockPreviewBanner() card("Local agents") { Text("\(env.agentRuntime.manifests.count) example agents registered.") } card("Installed SELF Apps") { Text("\(env.selfApps.count) demo apps in the local registry.") } @@ -25,6 +26,7 @@ struct HomeView: View { } .padding() } + .accessibilityIdentifier(UiTestTags.homeScreen) .navigationTitle("Home") } } diff --git a/apps/self-shell-ios/SelfOSPI/Views/SettingsView.swift b/apps/self-shell-ios/SelfOSPI/Views/SettingsView.swift index f9eea15..02e964a 100644 --- a/apps/self-shell-ios/SelfOSPI/Views/SettingsView.swift +++ b/apps/self-shell-ios/SelfOSPI/Views/SettingsView.swift @@ -17,6 +17,7 @@ struct SettingsView: View { .pickerStyle(.segmented) } Section("About") { + Text("Version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—")") Text("SELF OS Personal Intelligence keeps agents and mock services on-device. See docs/OPEN_SOURCE_BOUNDARY.md in the repository.") .font(.caption) } diff --git a/apps/self-shell-ios/SelfOSPIUITests/SelfOSPIUITests.swift b/apps/self-shell-ios/SelfOSPIUITests/SelfOSPIUITests.swift index 2451000..e531ecb 100644 --- a/apps/self-shell-ios/SelfOSPIUITests/SelfOSPIUITests.swift +++ b/apps/self-shell-ios/SelfOSPIUITests/SelfOSPIUITests.swift @@ -6,9 +6,8 @@ final class LaunchSmokeTests: XCTestCase { let app = XCUIApplication() app.launch() - let headline = app.staticTexts["SELF OS Personal Intelligence (Community Edition)"] - XCTAssertTrue(headline.waitForExistence(timeout: 10)) - + XCTAssertTrue(app.descendants(matching: .any)["home_screen"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.descendants(matching: .any)["home_welcome"].exists) XCTAssertTrue(app.tabBars.buttons["Home"].exists) } } diff --git a/apps/self-shell/build.gradle.kts b/apps/self-shell/build.gradle.kts index fa5d141..fdd5838 100644 --- a/apps/self-shell/build.gradle.kts +++ b/apps/self-shell/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "ai.selflabs.selfshell" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "0.1.0-community" + versionCode = 2 + versionName = "0.1.2-community" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -42,6 +42,7 @@ android { buildFeatures { compose = true + buildConfig = true } composeOptions { diff --git a/apps/self-shell/src/androidTest/java/ai/selflabs/selfshell/LaunchSmokeTest.kt b/apps/self-shell/src/androidTest/java/ai/selflabs/selfshell/LaunchSmokeTest.kt index 089157c..60f380f 100644 --- a/apps/self-shell/src/androidTest/java/ai/selflabs/selfshell/LaunchSmokeTest.kt +++ b/apps/self-shell/src/androidTest/java/ai/selflabs/selfshell/LaunchSmokeTest.kt @@ -2,8 +2,9 @@ package ai.selflabs.selfshell import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import ai.selflabs.selfshell.ui.UiTestTags import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -19,12 +20,12 @@ class LaunchSmokeTest { fun launchesHomeScreen() { composeTestRule.waitUntil(timeoutMillis = 15_000) { composeTestRule - .onAllNodesWithText("Welcome to your local personal intelligence shell.", useUnmergedTree = true) + .onAllNodesWithTag(UiTestTags.HOME_SCREEN, useUnmergedTree = true) .fetchSemanticsNodes() .isNotEmpty() } - composeTestRule - .onNodeWithText("Welcome to your local personal intelligence shell.") - .assertIsDisplayed() + composeTestRule.onNodeWithTag(UiTestTags.HOME_SCREEN).assertIsDisplayed() + composeTestRule.onNodeWithTag(UiTestTags.HOME_WELCOME).assertIsDisplayed() + composeTestRule.onNodeWithTag(UiTestTags.TAB_HOME).assertIsDisplayed() } } diff --git a/apps/self-shell/src/main/java/ai/selflabs/selfshell/MainActivity.kt b/apps/self-shell/src/main/java/ai/selflabs/selfshell/MainActivity.kt index ddebfee..1eb9fa1 100644 --- a/apps/self-shell/src/main/java/ai/selflabs/selfshell/MainActivity.kt +++ b/apps/self-shell/src/main/java/ai/selflabs/selfshell/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -31,6 +32,7 @@ import ai.selflabs.selfshell.ui.home.HomeScreen import ai.selflabs.selfshell.ui.mesh.MeshPreviewScreen import ai.selflabs.selfshell.ui.settings.SettingsScreen import ai.selflabs.selfshell.ui.theme.SelfShellTheme +import ai.selflabs.selfshell.ui.UiTestTags import ai.selflabs.selfshell.ui.wallet.WalletPreviewScreen class MainActivity : ComponentActivity() { @@ -62,7 +64,8 @@ class MainActivity : ComponentActivity() { } }, icon = { Icon(Icons.Default.Home, contentDescription = null) }, - label = { Text("Home") } + label = { Text("Home") }, + modifier = Modifier.testTag(UiTestTags.TAB_HOME) ) NavigationBarItem( selected = route == Routes.Agents, diff --git a/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/UiTestTags.kt b/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/UiTestTags.kt new file mode 100644 index 0000000..5262f1e --- /dev/null +++ b/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/UiTestTags.kt @@ -0,0 +1,7 @@ +package ai.selflabs.selfshell.ui + +object UiTestTags { + const val HOME_SCREEN = "home_screen" + const val HOME_WELCOME = "home_welcome" + const val TAB_HOME = "tab_home" +} diff --git a/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/home/HomeScreen.kt b/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/home/HomeScreen.kt index 3f60ea2..9e14027 100644 --- a/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/home/HomeScreen.kt +++ b/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/home/HomeScreen.kt @@ -17,12 +17,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import ai.selflabs.selfshell.SelfShellApplication import ai.selflabs.selfshell.mock.harmony.MeshNodeStatus import ai.selflabs.selfshell.mock.wallet.MockBalance import ai.selflabs.selfshell.ui.components.MockPreviewBanner import ai.selflabs.selfshell.ui.components.PreviewDisclaimer +import ai.selflabs.selfshell.ui.UiTestTags @Composable fun HomeScreen(app: SelfShellApplication) { var node by remember { mutableStateOf(null) } @@ -37,6 +39,7 @@ fun HomeScreen(app: SelfShellApplication) { Column( modifier = Modifier .fillMaxSize() + .testTag(UiTestTags.HOME_SCREEN) .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) @@ -47,7 +50,8 @@ fun HomeScreen(app: SelfShellApplication) { ) Text( text = "Welcome to your local personal intelligence shell.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(UiTestTags.HOME_WELCOME) ) MockPreviewBanner() diff --git a/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/settings/SettingsScreen.kt b/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/settings/SettingsScreen.kt index 9700ba7..70f6d96 100644 --- a/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/settings/SettingsScreen.kt +++ b/apps/self-shell/src/main/java/ai/selflabs/selfshell/ui/settings/SettingsScreen.kt @@ -1,6 +1,7 @@ package ai.selflabs.selfshell.ui.settings import ai.selflabs.selfshell.SelfShellApplication +import ai.selflabs.selfshell.BuildConfig import ai.selflabs.selfshell.ThemeChoice import ai.selflabs.selfshell.mock.aura.MockDataCategory import androidx.compose.foundation.layout.Arrangement @@ -58,6 +59,11 @@ fun SettingsScreen(app: SelfShellApplication) { ) { Text("Settings", style = MaterialTheme.typography.headlineSmall) + Text( + text = "Version ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.bodyMedium + ) + RowSwitch("Local-first mode (recommended)", localMode) { localMode = it } RowSwitch("Developer mode", devMode) { devMode = it } diff --git a/mock/aura-data/build.gradle.kts b/mock/aura-data/build.gradle.kts index 6fae67d..fb8d94e 100644 --- a/mock/aura-data/build.gradle.kts +++ b/mock/aura-data/build.gradle.kts @@ -19,4 +19,7 @@ android { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } diff --git a/mock/aura-data/src/test/java/ai/selflabs/selfshell/mock/aura/MockAuraDataClientTest.kt b/mock/aura-data/src/test/java/ai/selflabs/selfshell/mock/aura/MockAuraDataClientTest.kt new file mode 100644 index 0000000..29ea3b7 --- /dev/null +++ b/mock/aura-data/src/test/java/ai/selflabs/selfshell/mock/aura/MockAuraDataClientTest.kt @@ -0,0 +1,47 @@ +package ai.selflabs.selfshell.mock.aura + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MockAuraDataClientTest { + + private val client = MockAuraDataClient() + + @Test + fun listMockDataCategories_returnsEducationalSamples() = runTest { + val categories = client.listMockDataCategories() + + assertEquals(2, categories.size) + assertTrue(categories.any { it.id == "cat-usage" }) + assertTrue(categories.any { it.id == "cat-diagnostics" }) + } + + @Test + fun updateConsentPreference_persistsInSnapshot() = runTest { + client.updateConsentPreference(ConsentPreference("cat-usage", true)) + + val snapshot = client.snapshotConsent() + assertTrue(snapshot["cat-usage"] == true) + assertFalse(snapshot["cat-diagnostics"] == true) + } + + @Test + fun estimateMockDataValue_returnsPreviewOnlyCopy() = runTest { + val estimate = client.estimateMockDataValue("cat-usage") + + assertEquals("cat-usage", estimate.categoryId) + assertTrue(estimate.displayValue.contains("mock", ignoreCase = true)) + assertTrue(estimate.note.contains("Preview only", ignoreCase = true)) + } + + @Test + fun snapshotConsent_startsWithDefaultsOff() { + val snapshot = client.snapshotConsent() + + assertEquals(false, snapshot["cat-usage"]) + assertEquals(false, snapshot["cat-diagnostics"]) + } +} diff --git a/sdk/agent-sdk/src/test/java/ai/selflabs/selfshell/sdk/agent/DefaultAgentRuntimeTest.kt b/sdk/agent-sdk/src/test/java/ai/selflabs/selfshell/sdk/agent/DefaultAgentRuntimeTest.kt index ea643a2..91d26c9 100644 --- a/sdk/agent-sdk/src/test/java/ai/selflabs/selfshell/sdk/agent/DefaultAgentRuntimeTest.kt +++ b/sdk/agent-sdk/src/test/java/ai/selflabs/selfshell/sdk/agent/DefaultAgentRuntimeTest.kt @@ -76,6 +76,37 @@ class DefaultAgentRuntimeTest { assertNull(runtime.getAgent("stub-1")) } + @Test + fun sendRequest_throwingAgent_returnsError() = runTest { + runtime.registerAgent(ThrowingAgent("stub-throw")) + + val response = runtime.sendRequest("stub-throw", "ping") + + assertEquals(AgentRunStatus.ERROR, response.status) + assertTrue(response.output.contains("boom")) + assertEquals("RuntimeException", response.metadata["exception"]) + } + + private class ThrowingAgent(private val id: String) : Agent { + override fun getManifest(): AgentManifest = AgentManifest( + id = id, + name = "Throwing", + description = "Test agent", + version = "1.0.0", + author = "tests", + permissions = emptySet(), + entryPoint = "stub" + ) + + override suspend fun initialize() {} + + override suspend fun handleRequest(request: AgentRequest): AgentResponse { + throw RuntimeException("boom") + } + + override suspend fun shutdown() {} + } + private class StubAgent( private val id: String, private val reply: String diff --git a/sdk/app-sdk/build.gradle.kts b/sdk/app-sdk/build.gradle.kts index 60e3b25..29abfff 100644 --- a/sdk/app-sdk/build.gradle.kts +++ b/sdk/app-sdk/build.gradle.kts @@ -19,4 +19,7 @@ android { dependencies { implementation("androidx.annotation:annotation:1.7.1") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.8.0") } diff --git a/sdk/app-sdk/src/main/java/ai/selflabs/selfshell/sdk/app/DefaultPluginRegistry.kt b/sdk/app-sdk/src/main/java/ai/selflabs/selfshell/sdk/app/DefaultPluginRegistry.kt index db659cc..2c807db 100644 --- a/sdk/app-sdk/src/main/java/ai/selflabs/selfshell/sdk/app/DefaultPluginRegistry.kt +++ b/sdk/app-sdk/src/main/java/ai/selflabs/selfshell/sdk/app/DefaultPluginRegistry.kt @@ -8,7 +8,7 @@ class DefaultPluginRegistry : PluginRegistry { private val apps = ConcurrentHashMap() override fun registerApp(app: SelfApp) { - apps[app.getManifest().id] = app + apps.put(app.getManifest().id, app)?.stop() } override fun unregisterApp(appId: String) { diff --git a/sdk/app-sdk/src/test/java/ai/selflabs/selfshell/sdk/app/DefaultPluginRegistryTest.kt b/sdk/app-sdk/src/test/java/ai/selflabs/selfshell/sdk/app/DefaultPluginRegistryTest.kt new file mode 100644 index 0000000..fff69a0 --- /dev/null +++ b/sdk/app-sdk/src/test/java/ai/selflabs/selfshell/sdk/app/DefaultPluginRegistryTest.kt @@ -0,0 +1,117 @@ +package ai.selflabs.selfshell.sdk.app + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class DefaultPluginRegistryTest { + + private val registry = DefaultPluginRegistry() + private val context: Context = mock(Context::class.java) + + @Test + fun registerApp_listsManifest() { + registry.registerApp(stubApp("app-1")) + + assertEquals(1, registry.listPlugins().size) + assertEquals("app-1", registry.listPlugins().first().id) + } + + @Test + fun registerApp_replacesSameId() { + var stopCount = 0 + registry.registerApp(stubApp("app-1", onStop = { stopCount++ })) + registry.registerApp(stubApp("app-1")) + + assertEquals(1, registry.listPlugins().size) + assertEquals(1, stopCount) + } + + @Test + fun launchApp_unknownId_fails() { + val result = registry.launchApp(context, "missing") + + assertTrue(result.isFailure) + } + + @Test + fun launchApp_registeredApp_succeeds() { + var launched = false + registry.registerApp(stubApp("app-1", onLaunch = { launched = true })) + + val result = registry.launchApp(context, "app-1") + + assertTrue(result.isSuccess) + assertTrue(launched) + } + + @Test + fun launchApp_throwingApp_fails() { + registry.registerApp( + stubApp("app-1", onLaunch = { throw IllegalStateException("boom") }) + ) + + val result = registry.launchApp(context, "app-1") + + assertTrue(result.isFailure) + } + + @Test + fun uninstallApp_removesAndStops() { + var stopped = false + registry.registerApp(stubApp("app-1", onStop = { stopped = true })) + + registry.uninstallApp("app-1") + + assertTrue(stopped) + assertNull(registry.getApp("app-1")) + assertTrue(registry.listPlugins().isEmpty()) + } + + @Test + fun unregisterApp_removesAndStops() { + var stopped = false + registry.registerApp(stubApp("app-1", onStop = { stopped = true })) + + registry.unregisterApp("app-1") + + assertTrue(stopped) + assertFalse(registry.listPlugins().isNotEmpty()) + } + + @Test + fun getApp_returnsRegisteredInstance() { + val app = stubApp("app-1") + registry.registerApp(app) + + assertNotNull(registry.getApp("app-1")) + assertEquals("app-1", registry.getApp("app-1")?.getManifest()?.id) + } + + private fun stubApp( + id: String, + onLaunch: () -> Unit = {}, + onStop: () -> Unit = {} + ): SelfApp = object : SelfApp { + override fun getManifest(): SelfAppManifest = SelfAppManifest( + id = id, + name = "Test App", + description = "Stub", + version = "1.0.0", + author = "tests", + icon = "ic_test", + category = "Demo", + permissions = emptySet(), + entryPoint = "stub" + ) + + override fun launch(context: Context) = onLaunch() + + override fun stop() = onStop() + } +}