Skip to content

Commit 727bfe1

Browse files
committed
Merge remote-tracking branch 'origin/main' into copilot/add-ui-instrumentation-test
2 parents 44f7215 + 15d3074 commit 727bfe1

79 files changed

Lines changed: 5490 additions & 3886 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/android-build.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
path: llm/android/LlamaDemo
3232
- name: DeepLabV3Demo
3333
path: dl3/android/DeepLabV3Demo
34+
- name: MV3Demo
35+
path: mv3/android/MV3Demo
36+
3437

3538
name: Build ${{ matrix.name }}
3639
steps:
@@ -108,6 +111,7 @@ jobs:
108111
## Apps included:
109112
- LlamaDemo APKs (`app-debug-LlamaDemo.apk`, `app-release-unsigned-LlamaDemo.apk`)
110113
- DeepLabV3Demo APKs (`app-debug-DeepLabV3Demo.apk`, `app-release-unsigned-DeepLabV3Demo.apk`)
114+
- MV3Demo APKs (`app-debug-MV3Demo.apk`, `app-release-unsigned-MV3Demo.apk`)
111115
112116
**Build Date:** ${{ steps.tag.outputs.build_date }}
113117
**Commit:** ${{ github.sha }}

.github/workflows/llm-android.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ on:
3232
description: 'Custom URL for tokenizer file (only used when model_preset is custom)'
3333
required: false
3434
type: string
35+
local_aar:
36+
description: 'URL to download a local AAR file. When set, the workflow will download the AAR and use it instead of the Maven dependency.'
37+
required: false
38+
type: string
3539

3640
permissions:
3741
contents: read
@@ -74,6 +78,12 @@ jobs:
7478
- name: Setup Gradle
7579
uses: gradle/actions/setup-gradle@v4
7680

81+
- name: Download local AAR
82+
if: ${{ inputs.local_aar }}
83+
run: |
84+
mkdir -p llm/android/LlamaDemo/app/libs
85+
curl -fL -o llm/android/LlamaDemo/app/libs/executorch.aar "${{ inputs.local_aar }}"
86+
7787
- name: AVD cache
7888
uses: actions/cache@v4
7989
id: avd-cache
@@ -145,6 +155,7 @@ jobs:
145155
uses: reactivecircus/android-emulator-runner@v2
146156
env:
147157
MODEL_PRESET: ${{ inputs.model_preset || 'stories' }}
158+
USE_LOCAL_AAR: ${{ inputs.local_aar != '' }}
148159
with:
149160
api-level: ${{ env.API_LEVEL }}
150161
arch: ${{ env.ARCH }}
@@ -154,7 +165,7 @@ jobs:
154165
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save -memory 16384
155166
disable-animations: true
156167
working-directory: llm/android/LlamaDemo
157-
script: bash ./scripts/run-ci-tests.sh "$MODEL_PRESET" "$MODEL_FILE" "$TOKENIZER_FILE"
168+
script: bash ./scripts/run-ci-tests.sh "$MODEL_PRESET" "$MODEL_FILE" "$TOKENIZER_FILE" "$USE_LOCAL_AAR"
158169

159170
- name: Add model response to summary
160171
if: always()

llm/android/LlamaDemo/app/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,19 +244,22 @@ android {
244244
}
245245
kotlinOptions { jvmTarget = "1.8" }
246246
buildFeatures { compose = true }
247-
composeOptions { kotlinCompilerExtensionVersion = "1.4.3" }
247+
composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
248248
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
249249
}
250250

251251
dependencies {
252252
implementation("androidx.core:core-ktx:1.9.0")
253253
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
254+
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
254255
implementation("androidx.activity:activity-compose:1.7.0")
255256
implementation(platform("androidx.compose:compose-bom:2023.03.00"))
256257
implementation("androidx.compose.ui:ui")
257258
implementation("androidx.compose.ui:ui-graphics")
258259
implementation("androidx.compose.ui:ui-tooling-preview")
259260
implementation("androidx.compose.material3:material3")
261+
implementation("androidx.compose.material:material-icons-extended")
262+
implementation("io.coil-kt:coil-compose:2.4.0")
260263
implementation("androidx.appcompat:appcompat:1.6.1")
261264
implementation("androidx.camera:camera-core:1.3.0-rc02")
262265
implementation("androidx.constraintlayout:constraintlayout:2.2.0-alpha12")
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
package com.example.executorchllamademo
10+
11+
import android.content.Context
12+
import android.util.Log
13+
import androidx.compose.ui.semantics.SemanticsProperties
14+
import androidx.compose.ui.test.assertIsEnabled
15+
import androidx.compose.ui.test.hasContentDescription
16+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
17+
import androidx.compose.ui.test.onAllNodesWithText
18+
import androidx.compose.ui.test.onNodeWithContentDescription
19+
import androidx.compose.ui.test.onNodeWithTag
20+
import androidx.compose.ui.test.onNodeWithText
21+
import androidx.compose.ui.test.performClick
22+
import androidx.compose.ui.test.performTextInput
23+
import androidx.test.core.app.ApplicationProvider
24+
import androidx.test.ext.junit.runners.AndroidJUnit4
25+
import androidx.test.filters.LargeTest
26+
import org.junit.Assert.assertTrue
27+
import org.junit.Before
28+
import org.junit.Ignore
29+
import org.junit.Rule
30+
import org.junit.Test
31+
import org.junit.runner.RunWith
32+
33+
/**
34+
* Preset model sanity test that validates the preset model download and chat workflow.
35+
*
36+
* This test validates:
37+
* 1. Navigate from Welcome screen to Preset model screen
38+
* 2. Select Stories 110M and download it
39+
* 3. After download completes, tap to load and enter chat view
40+
* 4. Type "Once upon a time" and generate a response
41+
*/
42+
@RunWith(AndroidJUnit4::class)
43+
@LargeTest
44+
class PresetSanityTest {
45+
46+
companion object {
47+
private const val TAG = "PresetSanityTest"
48+
private const val RESPONSE_TAG = "LLAMA_RESPONSE"
49+
}
50+
51+
@get:Rule
52+
val composeTestRule = createAndroidComposeRule<WelcomeActivity>()
53+
54+
@Before
55+
fun setUp() {
56+
// Clear SharedPreferences before test to ensure a clean state
57+
val context = ApplicationProvider.getApplicationContext<Context>()
58+
val prefs = context.getSharedPreferences(
59+
context.getString(R.string.demo_pref_file_key),
60+
Context.MODE_PRIVATE
61+
)
62+
prefs.edit().clear().commit()
63+
}
64+
65+
/**
66+
* Types text into the chat input field using testTag.
67+
*/
68+
private fun typeInChatInput(text: String) {
69+
composeTestRule.onNodeWithTag("chat_input_field").performClick()
70+
composeTestRule.waitForIdle()
71+
composeTestRule.onNodeWithTag("chat_input_field").performTextInput(text)
72+
composeTestRule.waitForIdle()
73+
}
74+
75+
/**
76+
* Waits for generation to complete by checking for tokens-per-second metrics.
77+
*/
78+
private fun waitForGenerationComplete(timeoutMs: Long = 120000): Boolean {
79+
return try {
80+
composeTestRule.waitUntil(timeoutMillis = timeoutMs) {
81+
val tpsNodes = composeTestRule.onAllNodesWithText("t/s", substring = true)
82+
.fetchSemanticsNodes()
83+
val tokpsNodes = composeTestRule.onAllNodesWithText("tok/s", substring = true)
84+
.fetchSemanticsNodes()
85+
tpsNodes.isNotEmpty() || tokpsNodes.isNotEmpty()
86+
}
87+
Log.i(TAG, "Generation complete - found generation metrics")
88+
true
89+
} catch (e: Exception) {
90+
Log.e(TAG, "Generation timed out after ${timeoutMs}ms")
91+
false
92+
}
93+
}
94+
95+
/**
96+
* Waits for the model to be loaded by checking for success or error messages.
97+
*/
98+
private fun waitForModelLoaded(timeoutMs: Long = 60000): Boolean {
99+
return try {
100+
var wasSuccess = false
101+
composeTestRule.waitUntil(timeoutMillis = timeoutMs) {
102+
val successNodes = composeTestRule.onAllNodesWithText("Successfully loaded", substring = true)
103+
.fetchSemanticsNodes()
104+
val errorNodes = composeTestRule.onAllNodesWithText("Model load failure", substring = true)
105+
.fetchSemanticsNodes()
106+
wasSuccess = successNodes.isNotEmpty()
107+
successNodes.isNotEmpty() || errorNodes.isNotEmpty()
108+
}
109+
if (wasSuccess) {
110+
Log.i(TAG, "Model loaded successfully")
111+
} else {
112+
Log.e(TAG, "Model load failed")
113+
}
114+
wasSuccess
115+
} catch (e: Exception) {
116+
Log.e(TAG, "Model loading timed out after ${timeoutMs}ms: ${e.message}")
117+
false
118+
}
119+
}
120+
121+
/**
122+
* Verifies that the model generated a non-empty response.
123+
*/
124+
private fun assertModelResponseNotEmpty(timeoutMs: Long = 10000) {
125+
try {
126+
composeTestRule.waitUntil(timeoutMillis = timeoutMs) {
127+
val tpsNodes = composeTestRule.onAllNodesWithText("t/s", substring = true)
128+
.fetchSemanticsNodes()
129+
val tokpsNodes = composeTestRule.onAllNodesWithText("tok/s", substring = true)
130+
.fetchSemanticsNodes()
131+
tpsNodes.isNotEmpty() || tokpsNodes.isNotEmpty()
132+
}
133+
Log.i(TAG, "Model response verified - found generation metrics")
134+
} catch (e: Exception) {
135+
throw AssertionError("Model response appears to be empty - no generation metrics found after ${timeoutMs}ms")
136+
}
137+
}
138+
139+
/**
140+
* Logs the model response text for CI output.
141+
*/
142+
private fun logModelResponse() {
143+
try {
144+
Log.i(RESPONSE_TAG, "BEGIN_RESPONSE")
145+
val responseNodes = composeTestRule.onAllNodesWithText("t/s", substring = true)
146+
.fetchSemanticsNodes()
147+
for (node in responseNodes) {
148+
val text = node.config.getOrElse(SemanticsProperties.Text) { emptyList() }
149+
.joinToString(" ") { it.text }
150+
if (text.isNotBlank()) {
151+
Log.i(RESPONSE_TAG, text)
152+
}
153+
}
154+
Log.i(RESPONSE_TAG, "END_RESPONSE")
155+
} catch (e: Exception) {
156+
Log.d(TAG, "Could not log model response: ${e.message}")
157+
}
158+
}
159+
160+
/**
161+
* Tests the complete preset model download and chat workflow:
162+
* 1. From Welcome screen, tap "Preset model" card
163+
* 2. Find Stories 110M and tap Download
164+
* 3. Wait for download to complete
165+
* 4. Tap the card to load model and enter chat
166+
* 5. Type "Once upon a time" and send
167+
* 6. Verify response is generated
168+
*/
169+
@Ignore("Temporarily disabled")
170+
@Test
171+
fun testPresetModelDownloadAndChat() {
172+
composeTestRule.waitForIdle()
173+
174+
// Step 1: From Welcome screen, tap "Preset model" card
175+
Log.i(TAG, "Step 1: Navigating to Preset model screen")
176+
composeTestRule.onNodeWithText("Preset model").performClick()
177+
composeTestRule.waitUntil(timeoutMillis = 5000) {
178+
composeTestRule.onAllNodesWithText("Download Preset Model").fetchSemanticsNodes().isNotEmpty()
179+
}
180+
181+
// Step 2: Find Stories 110M and tap Download
182+
Log.i(TAG, "Step 2: Finding Stories 110M and starting download")
183+
composeTestRule.onNodeWithText("Stories 110M").assertExists()
184+
185+
// Check if already downloaded (Ready to use) or needs download
186+
val readyNodes = composeTestRule.onAllNodesWithText("Ready to use", substring = true)
187+
.fetchSemanticsNodes()
188+
189+
if (readyNodes.isEmpty()) {
190+
// Need to download - click Download button
191+
composeTestRule.onNodeWithText("Download").performClick()
192+
193+
// Step 3: Wait for download to complete (may take a while for large files)
194+
Log.i(TAG, "Step 3: Waiting for download to complete")
195+
composeTestRule.waitUntil(timeoutMillis = 300000) { // 5 minutes timeout for download
196+
composeTestRule.onAllNodesWithText("Ready to use", substring = true)
197+
.fetchSemanticsNodes().isNotEmpty()
198+
}
199+
Log.i(TAG, "Download completed")
200+
} else {
201+
Log.i(TAG, "Model already downloaded, skipping download step")
202+
}
203+
204+
// Step 4: Tap the card to load model and enter chat
205+
Log.i(TAG, "Step 4: Tapping card to load model")
206+
composeTestRule.onNodeWithText("Stories 110M").performClick()
207+
208+
// Wait for Activity transition - MainActivity needs time to launch and set content
209+
// The SelectPresetModelActivity calls finish() after starting MainActivity
210+
Thread.sleep(2000)
211+
212+
// Wait for model to load and chat screen to appear
213+
Log.i(TAG, "Waiting for model to load")
214+
val modelLoaded = waitForModelLoaded(90000)
215+
assertTrue("Model should be loaded successfully", modelLoaded)
216+
Log.i(TAG, "Model loaded successfully")
217+
218+
// Step 5: Type "Once upon a time" and send
219+
Log.i(TAG, "Step 5: Typing prompt and sending")
220+
typeInChatInput("Once upon a time")
221+
222+
// Wait for send button to be enabled
223+
composeTestRule.waitUntil(timeoutMillis = 5000) {
224+
try {
225+
composeTestRule.onNodeWithContentDescription("Send").assertIsEnabled()
226+
true
227+
} catch (e: AssertionError) {
228+
false
229+
}
230+
}
231+
232+
composeTestRule.onNodeWithContentDescription("Send").performClick()
233+
composeTestRule.waitForIdle()
234+
235+
// Step 6: Wait for generation to complete and verify response
236+
Log.i(TAG, "Step 6: Waiting for generation to complete")
237+
val generationComplete = waitForGenerationComplete(120000)
238+
assertTrue("Generation should complete", generationComplete)
239+
240+
assertModelResponseNotEmpty()
241+
logModelResponse()
242+
243+
Log.i(TAG, "Preset model sanity test completed successfully")
244+
}
245+
}

0 commit comments

Comments
 (0)