androjack-mcp 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +34 -0
- package/.github/pull_request_template.md +16 -0
- package/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +592 -0
- package/SECURITY.md +26 -0
- package/assets/AndroJack banner.png +0 -0
- package/assets/killer_argument.png +0 -0
- package/build/constants.js +412 -0
- package/build/http-server.js +163 -0
- package/build/http.js +151 -0
- package/build/index.js +553 -0
- package/build/install.js +379 -0
- package/build/logger.js +57 -0
- package/build/tools/api-level.js +170 -0
- package/build/tools/api36-compliance.js +282 -0
- package/build/tools/architecture.js +75 -0
- package/build/tools/build-publish.js +362 -0
- package/build/tools/component.js +90 -0
- package/build/tools/debugger.js +82 -0
- package/build/tools/gradle.js +234 -0
- package/build/tools/kmp.js +348 -0
- package/build/tools/kotlin-patterns.js +500 -0
- package/build/tools/large-screen.js +366 -0
- package/build/tools/m3-expressive.js +447 -0
- package/build/tools/navigation3.js +331 -0
- package/build/tools/ondevice-ai.js +283 -0
- package/build/tools/permissions.js +404 -0
- package/build/tools/play-policy.js +221 -0
- package/build/tools/scalability.js +621 -0
- package/build/tools/search.js +89 -0
- package/build/tools/testing.js +439 -0
- package/build/tools/wear.js +337 -0
- package/build/tools/xr.js +274 -0
- package/config/antigravity_mcp.json +32 -0
- package/config/claude_desktop_config.json +17 -0
- package/config/cursor_mcp.json +21 -0
- package/config/jetbrains_mcp.json +28 -0
- package/config/kiro_mcp.json +40 -0
- package/config/vscode_mcp.json +24 -0
- package/config/windsurf_mcp.json +18 -0
- package/package.json +51 -0
- package/src/constants.ts +436 -0
- package/src/http-server.ts +186 -0
- package/src/http.ts +190 -0
- package/src/index.ts +702 -0
- package/src/install.ts +441 -0
- package/src/logger.ts +67 -0
- package/src/tools/api-level.ts +198 -0
- package/src/tools/api36-compliance.ts +289 -0
- package/src/tools/architecture.ts +94 -0
- package/src/tools/build-publish.ts +379 -0
- package/src/tools/component.ts +106 -0
- package/src/tools/debugger.ts +111 -0
- package/src/tools/gradle.ts +288 -0
- package/src/tools/kmp.ts +352 -0
- package/src/tools/kotlin-patterns.ts +534 -0
- package/src/tools/large-screen.ts +391 -0
- package/src/tools/m3-expressive.ts +473 -0
- package/src/tools/navigation3.ts +338 -0
- package/src/tools/ondevice-ai.ts +287 -0
- package/src/tools/permissions.ts +445 -0
- package/src/tools/play-policy.ts +229 -0
- package/src/tools/scalability.ts +646 -0
- package/src/tools/search.ts +112 -0
- package/src/tools/testing.ts +460 -0
- package/src/tools/wear.ts +343 -0
- package/src/tools/xr.ts +278 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool 1 – android_official_search
|
|
3
|
+
*
|
|
4
|
+
* Searches developer.android.com, kotlinlang.org, and source.android.com
|
|
5
|
+
* restricted to authoritative domains only. Returns structured excerpts
|
|
6
|
+
* with source URLs so the LLM can cite them in generated code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { secureFetch, extractPageText } from "../http.js";
|
|
10
|
+
|
|
11
|
+
interface SearchResult {
|
|
12
|
+
title: string;
|
|
13
|
+
url: string;
|
|
14
|
+
excerpt: string;
|
|
15
|
+
domain: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Builds a Google site-restricted search URL. We use the Google Custom
|
|
20
|
+
* Search JSON API pattern but fall back to direct developer.android.com
|
|
21
|
+
* search if no API key is configured (KISS principle – works out of the box).
|
|
22
|
+
*/
|
|
23
|
+
function buildSearchUrl(query: string, domain: string): string {
|
|
24
|
+
const encoded = encodeURIComponent(query);
|
|
25
|
+
// developer.android.com has a public search endpoint
|
|
26
|
+
if (domain === "developer.android.com") {
|
|
27
|
+
return `https://developer.android.com/s/results?q=${encoded}`;
|
|
28
|
+
}
|
|
29
|
+
if (domain === "kotlinlang.org") {
|
|
30
|
+
return `https://kotlinlang.org/search.html#q=${encoded}`;
|
|
31
|
+
}
|
|
32
|
+
// Fallback: construct a direct reference URL
|
|
33
|
+
return `https://${domain}/search?q=${encoded}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fetches a single authoritative page and returns a structured result.
|
|
38
|
+
* Fails gracefully – one domain failing should not block others.
|
|
39
|
+
*/
|
|
40
|
+
async function fetchFromDomain(
|
|
41
|
+
query: string,
|
|
42
|
+
domain: string
|
|
43
|
+
): Promise<SearchResult | null> {
|
|
44
|
+
try {
|
|
45
|
+
const url = buildSearchUrl(query, domain);
|
|
46
|
+
const html = await secureFetch(url);
|
|
47
|
+
const text = extractPageText(html, 2000);
|
|
48
|
+
|
|
49
|
+
// Try to extract a page title
|
|
50
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
51
|
+
const title = titleMatch ? titleMatch[1].trim() : `${domain} – ${query}`;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
title,
|
|
55
|
+
url,
|
|
56
|
+
excerpt: text,
|
|
57
|
+
domain,
|
|
58
|
+
};
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
61
|
+
return {
|
|
62
|
+
title: `[Fetch failed: ${domain}]`,
|
|
63
|
+
url: buildSearchUrl(query, domain),
|
|
64
|
+
excerpt: `Could not retrieve: ${message}`,
|
|
65
|
+
domain,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Core handler for android_official_search.
|
|
72
|
+
* Queries all authoritative Android engineering domains in parallel.
|
|
73
|
+
*/
|
|
74
|
+
export async function androidOfficialSearch(query: string): Promise<string> {
|
|
75
|
+
if (!query || query.trim().length < 2) {
|
|
76
|
+
return "ERROR: Query must be at least 2 characters.";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sanitizedQuery = query.trim().slice(0, 200); // bound input length
|
|
80
|
+
|
|
81
|
+
const domains = [
|
|
82
|
+
"developer.android.com",
|
|
83
|
+
"kotlinlang.org",
|
|
84
|
+
"source.android.com",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Parallel fetch — ALLOWED_DOMAINS enforced inside secureFetch
|
|
88
|
+
const results = await Promise.allSettled(
|
|
89
|
+
domains.map((d) => fetchFromDomain(sanitizedQuery, d))
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const formatted = results
|
|
93
|
+
.map((r, i) => {
|
|
94
|
+
if (r.status === "rejected") {
|
|
95
|
+
return `### ${domains[i]}\n> Error: ${r.reason}`;
|
|
96
|
+
}
|
|
97
|
+
const res = r.value;
|
|
98
|
+
if (!res) return "";
|
|
99
|
+
return `### ${res.title}\n**Source:** ${res.url}\n\n${res.excerpt}`;
|
|
100
|
+
})
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join("\n\n---\n\n");
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
`## AndroJack Official Search Results\n` +
|
|
106
|
+
`**Query:** "${sanitizedQuery}"\n` +
|
|
107
|
+
`**Sources:** ${domains.join(", ")}\n\n` +
|
|
108
|
+
formatted +
|
|
109
|
+
`\n\n---\n` +
|
|
110
|
+
`> ⚠️ GROUNDING GATE: Only produce Android code after reviewing the above official sources.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool 10 – android_testing_guide
|
|
3
|
+
*
|
|
4
|
+
* Complete Android testing reference grounded in official docs.
|
|
5
|
+
* Targets coroutines-test 1.9+ (StandardTestDispatcher, not deprecated TestCoroutineDispatcher).
|
|
6
|
+
*
|
|
7
|
+
* Sources:
|
|
8
|
+
* - developer.android.com/training/testing
|
|
9
|
+
* - developer.android.com/develop/ui/compose/testing
|
|
10
|
+
* - developer.android.com/training/dependency-injection/hilt-testing
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface TestingTopic {
|
|
14
|
+
keywords: string[];
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Knowledge base ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const SETUP = `
|
|
21
|
+
## Android Testing Setup — Dependencies & Config
|
|
22
|
+
|
|
23
|
+
\`\`\`kotlin
|
|
24
|
+
// build.gradle.kts (app)
|
|
25
|
+
android {
|
|
26
|
+
defaultConfig {
|
|
27
|
+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
28
|
+
// For Hilt instrumented tests:
|
|
29
|
+
// testInstrumentationRunner = "com.yourapp.HiltTestRunner"
|
|
30
|
+
}
|
|
31
|
+
packaging {
|
|
32
|
+
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
|
33
|
+
}
|
|
34
|
+
testOptions {
|
|
35
|
+
unitTests.isReturnDefaultValues = true // avoids AndroidLog crashes in unit tests
|
|
36
|
+
animationsDisabled = true // prevent flaky UI test timing issues
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
dependencies {
|
|
41
|
+
// ── Unit tests (src/test — JVM only, fast) ──────────────────────────────
|
|
42
|
+
testImplementation("junit:junit:4.13.2")
|
|
43
|
+
testImplementation("io.mockk:mockk:1.13.14")
|
|
44
|
+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") // StandardTestDispatcher
|
|
45
|
+
testImplementation("app.cash.turbine:turbine:1.2.0") // Flow testing
|
|
46
|
+
testImplementation("androidx.arch.core:core-testing:2.2.0") // InstantTaskExecutorRule
|
|
47
|
+
testImplementation("com.google.truth:truth:1.4.4") // Fluent assertions
|
|
48
|
+
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
|
49
|
+
|
|
50
|
+
// ── Instrumented tests (src/androidTest — needs emulator/device) ────────
|
|
51
|
+
androidTestImplementation(platform("androidx.compose:compose-bom:2025.05.00"))
|
|
52
|
+
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
|
53
|
+
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
|
54
|
+
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
|
55
|
+
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
|
56
|
+
androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1")
|
|
57
|
+
androidTestImplementation("com.google.dagger:hilt-android-testing:2.52")
|
|
58
|
+
kspAndroidTest("com.google.dagger:hilt-compiler:2.52")
|
|
59
|
+
androidTestImplementation("androidx.navigation:navigation-testing:2.8.9")
|
|
60
|
+
androidTestImplementation("io.mockk:mockk-android:1.13.14")
|
|
61
|
+
}
|
|
62
|
+
\`\`\`
|
|
63
|
+
|
|
64
|
+
> 📚 Source: developer.android.com/training/testing
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const UNIT_TESTS = `
|
|
68
|
+
## Unit Testing — ViewModel, Repository, Room DAO
|
|
69
|
+
|
|
70
|
+
### MainDispatcherRule — CORRECT for coroutines-test 1.9+
|
|
71
|
+
|
|
72
|
+
\`\`\`kotlin
|
|
73
|
+
// Source: developer.android.com/kotlin/coroutines/test
|
|
74
|
+
// IMPORTANT: Use StandardTestDispatcher, not deprecated TestCoroutineDispatcher
|
|
75
|
+
class MainDispatcherRule(
|
|
76
|
+
private val dispatcher: TestDispatcher = StandardTestDispatcher()
|
|
77
|
+
) : TestWatcher() {
|
|
78
|
+
override fun starting(description: Description?) { Dispatchers.setMain(dispatcher) }
|
|
79
|
+
override fun finished(description: Description?) { Dispatchers.resetMain() }
|
|
80
|
+
}
|
|
81
|
+
\`\`\`
|
|
82
|
+
|
|
83
|
+
### ViewModel test with Turbine + StandardTestDispatcher
|
|
84
|
+
|
|
85
|
+
\`\`\`kotlin
|
|
86
|
+
class LoginViewModelTest {
|
|
87
|
+
|
|
88
|
+
@get:Rule val coroutineRule = MainDispatcherRule()
|
|
89
|
+
|
|
90
|
+
private val repository = mockk<AuthRepository>()
|
|
91
|
+
private lateinit var viewModel: LoginViewModel
|
|
92
|
+
|
|
93
|
+
@Before
|
|
94
|
+
fun setup() { viewModel = LoginViewModel(repository) }
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun \`login success emits LoggedIn state\`() = runTest {
|
|
98
|
+
// Arrange
|
|
99
|
+
coEvery { repository.login("user", "pass") } returns Result.success(User("user"))
|
|
100
|
+
|
|
101
|
+
viewModel.uiState.test {
|
|
102
|
+
viewModel.login("user", "pass")
|
|
103
|
+
|
|
104
|
+
// runTest + StandardTestDispatcher: advance time explicitly
|
|
105
|
+
val loading = awaitItem()
|
|
106
|
+
assertTrue(loading is UiState.Loading)
|
|
107
|
+
|
|
108
|
+
val success = awaitItem() as UiState.Success
|
|
109
|
+
assertEquals("user", success.data.name)
|
|
110
|
+
cancelAndIgnoreRemainingEvents()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@Test
|
|
115
|
+
fun \`login failure emits Error state\`() = runTest {
|
|
116
|
+
coEvery { repository.login(any(), any()) } returns Result.failure(IOException("Network error"))
|
|
117
|
+
|
|
118
|
+
viewModel.uiState.test {
|
|
119
|
+
viewModel.login("user", "wrong")
|
|
120
|
+
skipItems(1) // Loading
|
|
121
|
+
val error = awaitItem() as UiState.Error
|
|
122
|
+
assertEquals("Network error", error.message)
|
|
123
|
+
cancelAndIgnoreRemainingEvents()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
\`\`\`
|
|
128
|
+
|
|
129
|
+
### Repository test with MockWebServer
|
|
130
|
+
|
|
131
|
+
\`\`\`kotlin
|
|
132
|
+
class UserRepositoryTest {
|
|
133
|
+
private val server = MockWebServer()
|
|
134
|
+
private lateinit var repository: UserRepository
|
|
135
|
+
|
|
136
|
+
@Before
|
|
137
|
+
fun setup() {
|
|
138
|
+
server.start()
|
|
139
|
+
val retrofit = Retrofit.Builder()
|
|
140
|
+
.baseUrl(server.url("/"))
|
|
141
|
+
.addConverterFactory(GsonConverterFactory.create())
|
|
142
|
+
.build()
|
|
143
|
+
repository = UserRepository(retrofit.create(UserApi::class.java))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@After fun tearDown() { server.shutdown() }
|
|
147
|
+
|
|
148
|
+
@Test
|
|
149
|
+
fun \`fetchUser returns User on 200\`() = runTest {
|
|
150
|
+
server.enqueue(MockResponse().setBody("""{"id":1,"name":"Alice"}""").setResponseCode(200))
|
|
151
|
+
val result = repository.fetchUser(1)
|
|
152
|
+
assertTrue(result.isSuccess)
|
|
153
|
+
assertEquals("Alice", result.getOrNull()?.name)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@Test
|
|
157
|
+
fun \`fetchUser wraps 404 as failure\`() = runTest {
|
|
158
|
+
server.enqueue(MockResponse().setResponseCode(404))
|
|
159
|
+
val result = repository.fetchUser(1)
|
|
160
|
+
assertTrue(result.isFailure)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
\`\`\`
|
|
164
|
+
|
|
165
|
+
### Room in-memory database test
|
|
166
|
+
|
|
167
|
+
\`\`\`kotlin
|
|
168
|
+
@RunWith(AndroidJUnit4::class)
|
|
169
|
+
class UserDaoTest {
|
|
170
|
+
private lateinit var db: AppDatabase
|
|
171
|
+
private lateinit var dao: UserDao
|
|
172
|
+
|
|
173
|
+
@Before
|
|
174
|
+
fun setup() {
|
|
175
|
+
db = Room.inMemoryDatabaseBuilder(
|
|
176
|
+
ApplicationProvider.getApplicationContext(),
|
|
177
|
+
AppDatabase::class.java
|
|
178
|
+
).allowMainThreadQueries().build()
|
|
179
|
+
dao = db.userDao()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@After fun tearDown() { db.close() }
|
|
183
|
+
|
|
184
|
+
@Test
|
|
185
|
+
fun insertAndRetrieve() = runTest {
|
|
186
|
+
val user = User(id = 1, name = "Alice")
|
|
187
|
+
dao.insertUser(user)
|
|
188
|
+
val retrieved = dao.getUserById(1).first()
|
|
189
|
+
assertEquals(user, retrieved)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@Test
|
|
193
|
+
fun flowEmitsOnUpdate() = runTest {
|
|
194
|
+
dao.getUserById(1).test {
|
|
195
|
+
// Initially null or empty
|
|
196
|
+
dao.insertUser(User(1, "Alice"))
|
|
197
|
+
val item = awaitItem()
|
|
198
|
+
assertEquals("Alice", item?.name)
|
|
199
|
+
cancelAndIgnoreRemainingEvents()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
\`\`\`
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const COMPOSE_TESTING = `
|
|
207
|
+
## Compose UI Testing
|
|
208
|
+
|
|
209
|
+
\`\`\`kotlin
|
|
210
|
+
// Source: developer.android.com/develop/ui/compose/testing
|
|
211
|
+
|
|
212
|
+
@RunWith(AndroidJUnit4::class)
|
|
213
|
+
class LoginScreenTest {
|
|
214
|
+
|
|
215
|
+
@get:Rule val composeTestRule = createComposeRule()
|
|
216
|
+
|
|
217
|
+
@Test
|
|
218
|
+
fun \`login button disabled when fields empty\`() {
|
|
219
|
+
composeTestRule.setContent {
|
|
220
|
+
AppTheme { LoginScreen(onLoginSuccess = {}) }
|
|
221
|
+
}
|
|
222
|
+
composeTestRule.onNodeWithText("Sign In").assertIsNotEnabled()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@Test
|
|
226
|
+
fun \`successful login navigates to home\`() {
|
|
227
|
+
composeTestRule.setContent {
|
|
228
|
+
AppTheme { LoginScreen(onLoginSuccess = {}) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
composeTestRule.onNodeWithTag("emailField").performTextInput("alice@test.com")
|
|
232
|
+
composeTestRule.onNodeWithTag("passwordField").performTextInput("password123")
|
|
233
|
+
composeTestRule.onNodeWithText("Sign In").performClick()
|
|
234
|
+
|
|
235
|
+
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
236
|
+
composeTestRule.onAllNodesWithText("Welcome").fetchSemanticsNodes().isNotEmpty()
|
|
237
|
+
}
|
|
238
|
+
composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
\`\`\`
|
|
242
|
+
|
|
243
|
+
### Finders & assertions quick reference
|
|
244
|
+
|
|
245
|
+
\`\`\`kotlin
|
|
246
|
+
// Finders
|
|
247
|
+
onNodeWithText("Submit") // by text
|
|
248
|
+
onNodeWithTag("loginButton") // by testTag (most stable)
|
|
249
|
+
onNodeWithContentDescription("Close") // by accessibility description
|
|
250
|
+
onNode(hasRole(Role.Button) and isEnabled()) // combined matcher
|
|
251
|
+
|
|
252
|
+
// Assertions
|
|
253
|
+
.assertIsDisplayed()
|
|
254
|
+
.assertIsEnabled() / .assertIsNotEnabled()
|
|
255
|
+
.assertTextEquals("Expected")
|
|
256
|
+
.assertHasClickAction()
|
|
257
|
+
|
|
258
|
+
// Actions
|
|
259
|
+
.performClick()
|
|
260
|
+
.performTextInput("text")
|
|
261
|
+
.performTextClearance()
|
|
262
|
+
.performScrollTo()
|
|
263
|
+
.performTouchInput { swipeUp() }
|
|
264
|
+
|
|
265
|
+
// Waiting (use instead of Thread.sleep)
|
|
266
|
+
composeTestRule.waitUntil(5_000) {
|
|
267
|
+
onAllNodesWithTag("item").fetchSemanticsNodes().size >= 3
|
|
268
|
+
}
|
|
269
|
+
\`\`\`
|
|
270
|
+
|
|
271
|
+
### Anti-pattern
|
|
272
|
+
|
|
273
|
+
\`\`\`kotlin
|
|
274
|
+
// ❌ Fragile — breaks when text changes
|
|
275
|
+
onNodeWithText("Submit")[0].performClick()
|
|
276
|
+
|
|
277
|
+
// ✅ Stable — add to your Composable: Modifier.testTag("submitButton")
|
|
278
|
+
onNodeWithTag("submitButton").performClick()
|
|
279
|
+
\`\`\`
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
const ESPRESSO = `
|
|
283
|
+
## Espresso — View + Compose interop
|
|
284
|
+
|
|
285
|
+
\`\`\`kotlin
|
|
286
|
+
// Source: developer.android.com/training/testing/espresso
|
|
287
|
+
|
|
288
|
+
@RunWith(AndroidJUnit4::class)
|
|
289
|
+
@HiltAndroidTest
|
|
290
|
+
class MainActivityTest {
|
|
291
|
+
|
|
292
|
+
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
|
|
293
|
+
@get:Rule(order = 1) val activityRule = ActivityScenarioRule(MainActivity::class.java)
|
|
294
|
+
|
|
295
|
+
@Before fun setup() { hiltRule.inject() }
|
|
296
|
+
|
|
297
|
+
@Test
|
|
298
|
+
fun clickButtonShowsResult() {
|
|
299
|
+
onView(withId(R.id.submitButton))
|
|
300
|
+
.check(matches(isDisplayed()))
|
|
301
|
+
.perform(click())
|
|
302
|
+
onView(withText("Success")).check(matches(isDisplayed()))
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Mixed Espresso + Compose (interop)
|
|
307
|
+
@RunWith(AndroidJUnit4::class)
|
|
308
|
+
class HybridScreenTest {
|
|
309
|
+
@get:Rule val composeTestRule = createAndroidComposeRule<MainActivity>()
|
|
310
|
+
|
|
311
|
+
@Test
|
|
312
|
+
fun hybridInterop() {
|
|
313
|
+
// View layer
|
|
314
|
+
onView(withText("Hello Views")).check(matches(isDisplayed()))
|
|
315
|
+
// Compose layer
|
|
316
|
+
composeTestRule.onNodeWithText("Click here").performClick()
|
|
317
|
+
// Back to View
|
|
318
|
+
onView(withText("Updated")).check(matches(isDisplayed()))
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
\`\`\`
|
|
322
|
+
`;
|
|
323
|
+
|
|
324
|
+
const HILT_TESTING = `
|
|
325
|
+
## Hilt Dependency Injection in Tests
|
|
326
|
+
|
|
327
|
+
\`\`\`kotlin
|
|
328
|
+
// Source: developer.android.com/training/dependency-injection/hilt-testing
|
|
329
|
+
|
|
330
|
+
// Custom test runner (required for instrumented Hilt tests)
|
|
331
|
+
class HiltTestRunner : AndroidJUnitRunner() {
|
|
332
|
+
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?) =
|
|
333
|
+
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
|
334
|
+
}
|
|
335
|
+
// In build.gradle.kts: testInstrumentationRunner = "com.yourapp.HiltTestRunner"
|
|
336
|
+
|
|
337
|
+
@HiltAndroidTest
|
|
338
|
+
class UserFlowTest {
|
|
339
|
+
|
|
340
|
+
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
|
|
341
|
+
@get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<HiltTestActivity>()
|
|
342
|
+
|
|
343
|
+
// Override real binding with a fake
|
|
344
|
+
@BindValue @JvmField
|
|
345
|
+
val repository: UserRepository = FakeUserRepository()
|
|
346
|
+
|
|
347
|
+
@Before fun setup() { hiltRule.inject() }
|
|
348
|
+
|
|
349
|
+
@Test
|
|
350
|
+
fun userListDisplays() {
|
|
351
|
+
composeTestRule.onNodeWithTag("userList").assertIsDisplayed()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
class FakeUserRepository : UserRepository {
|
|
356
|
+
override fun getUsers() = flowOf(listOf(User(1, "Alice"), User(2, "Bob")))
|
|
357
|
+
override suspend fun refreshUsers() = Unit
|
|
358
|
+
}
|
|
359
|
+
\`\`\`
|
|
360
|
+
|
|
361
|
+
### Unit testing Hilt ViewModels (no instrumentation needed)
|
|
362
|
+
|
|
363
|
+
\`\`\`kotlin
|
|
364
|
+
// No @HiltAndroidTest needed — just inject fakes directly
|
|
365
|
+
class UserViewModelTest {
|
|
366
|
+
@get:Rule val coroutineRule = MainDispatcherRule()
|
|
367
|
+
private val fakeRepo = FakeUserRepository()
|
|
368
|
+
private val viewModel = UserViewModel(fakeRepo)
|
|
369
|
+
|
|
370
|
+
@Test
|
|
371
|
+
fun \`users are loaded on init\`() = runTest {
|
|
372
|
+
viewModel.uiState.test {
|
|
373
|
+
val state = awaitItem() as UiState.Success
|
|
374
|
+
assertEquals(2, state.data.size)
|
|
375
|
+
cancelAndIgnoreRemainingEvents()
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
\`\`\`
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
const TEST_PYRAMID = `
|
|
383
|
+
## Test Pyramid Strategy for Android
|
|
384
|
+
|
|
385
|
+
\`\`\`
|
|
386
|
+
/\\
|
|
387
|
+
/ \\ ← E2E / UI Tests (10%)
|
|
388
|
+
/ E2E \\ Espresso, Compose UI tests
|
|
389
|
+
/───────\\
|
|
390
|
+
/ \\ ← Integration Tests (20–30%)
|
|
391
|
+
/ Integration \\ Repository + DAO + ViewModel with fakes
|
|
392
|
+
/───────────────\\
|
|
393
|
+
/ \\ ← Unit Tests (60–70%)
|
|
394
|
+
/ Unit (JVM/JUnit4) \\ UseCases, mappers, domain logic, utils
|
|
395
|
+
\\─────────────────────/
|
|
396
|
+
\`\`\`
|
|
397
|
+
|
|
398
|
+
### Key rules
|
|
399
|
+
|
|
400
|
+
- **Unit tests** → \`src/test/\` — JVM only, no emulator, milliseconds to run
|
|
401
|
+
- **Instrumented tests** → \`src/androidTest/\` — needs emulator or device
|
|
402
|
+
- **Always** use in-memory Room DB for DAO tests — never real files
|
|
403
|
+
- **Always** use MockWebServer for network tests — never real endpoints
|
|
404
|
+
- **Always** use Turbine for Flow assertions — never \`runBlocking { flow.first() }\`
|
|
405
|
+
- **Always** use MainDispatcherRule with StandardTestDispatcher for ViewModel tests
|
|
406
|
+
- **Always** add \`testTag\` to interactive Compose elements — text matchers break with i18n
|
|
407
|
+
- **Target:** 70% unit · 20% integration · 10% UI for most apps
|
|
408
|
+
|
|
409
|
+
**Official guide:** https://developer.android.com/training/testing
|
|
410
|
+
**Compose testing:** https://developer.android.com/develop/ui/compose/testing
|
|
411
|
+
`;
|
|
412
|
+
|
|
413
|
+
// ── Topic routing ─────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
const TOPICS: TestingTopic[] = [
|
|
416
|
+
{ keywords: ["setup", "dependency", "dependencies", "gradle", "getting started", "config", "runner"], content: SETUP },
|
|
417
|
+
{ keywords: ["unit test", "viewmodel test", "repository test", "mockk", "turbine", "coroutine test", "flow test", "room test", "dao test", "main dispatcher", "standardtest"], content: UNIT_TESTS },
|
|
418
|
+
{ keywords: ["compose test", "composetestrul", "ui test", "semantics", "finder", "assertis", "performclick", "testtag", "compose ui", "waituntil"], content: COMPOSE_TESTING },
|
|
419
|
+
{ keywords: ["espresso", "view test", "onview", "activityscenario", "interop", "hybrid"], content: ESPRESSO },
|
|
420
|
+
{ keywords: ["hilt test", "hilt inject", "hiltandroidtest", "bindvalue", "fake", "hilt testing"], content: HILT_TESTING },
|
|
421
|
+
{ keywords: ["pyramid", "strategy", "how many", "coverage", "structure", "overview", "what to test", "ratio"], content: TEST_PYRAMID },
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
const INDEX = `
|
|
425
|
+
## Android Testing Guide
|
|
426
|
+
|
|
427
|
+
**Available topics:**
|
|
428
|
+
- \`setup\` — Gradle dependencies for all test types
|
|
429
|
+
- \`unit tests\` — ViewModel, Repository, Room DAO, MainDispatcherRule (StandardTestDispatcher)
|
|
430
|
+
- \`compose testing\` — ComposeTestRule, finders, actions, assertions, waitUntil
|
|
431
|
+
- \`espresso\` — View-based tests + Compose interop
|
|
432
|
+
- \`hilt testing\` — Custom test runner, @HiltAndroidTest, @BindValue fakes
|
|
433
|
+
- \`pyramid\` — Test strategy, ratios, key rules
|
|
434
|
+
|
|
435
|
+
**Official sources:**
|
|
436
|
+
- https://developer.android.com/training/testing
|
|
437
|
+
- https://developer.android.com/develop/ui/compose/testing
|
|
438
|
+
`;
|
|
439
|
+
|
|
440
|
+
export async function androidTestingGuide(topic: string): Promise<string> {
|
|
441
|
+
const trimmed = topic.trim().toLowerCase();
|
|
442
|
+
if (!trimmed || trimmed === "list" || trimmed === "help") return INDEX;
|
|
443
|
+
|
|
444
|
+
const found = TOPICS.find(t => t.keywords.some(k => trimmed.includes(k)));
|
|
445
|
+
if (found) {
|
|
446
|
+
return (
|
|
447
|
+
found.content.trim() +
|
|
448
|
+
`\n\n---\n> 🧪 GROUNDING GATE: Tests must follow the official patterns above.\n` +
|
|
449
|
+
`> Use StandardTestDispatcher (not deprecated TestCoroutineDispatcher), Turbine for Flows, MainDispatcherRule for ViewModels.`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
`## Android Testing: "${topic}"\n\n` +
|
|
455
|
+
`No built-in entry found. Check:\n` +
|
|
456
|
+
`- https://developer.android.com/training/testing\n` +
|
|
457
|
+
`- https://developer.android.com/develop/ui/compose/testing\n\n` +
|
|
458
|
+
INDEX
|
|
459
|
+
);
|
|
460
|
+
}
|