From 1a5687cdb67175dec10231c565e228bed54059c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 10:46:48 +0000 Subject: [PATCH 1/2] ci: cut CI time by caching Gradle output and skipping redundant work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the four pushes to #84 — nearly all of the wall clock is one step in each workflow, and both are dominated by Gradle recompiling from scratch: Android CI 19m36s of which "Test, lint and build APKs" 18m14s capture 17m02s of which the emulator step 16m15s (#83's log shows the gradle build alone accounting for 16m43s of that step) The emulator itself is not the problem; building the app twice from cold is. - Enable the Gradle build cache and parallel execution. `setup-gradle` already persists `~/.gradle` from main, but without `org.gradle.caching` only dependency jars were reused, never task output — so every run recompiled the whole app. - Run the screenshot workflow on main as well. `setup-gradle` writes its cache only from the default branch and keys it per job, so a job that never runs on main never has a warm cache to restore. `capture` has always started cold for exactly this reason; a run on merge gives pull requests something to restore. - Stop building the release APK on pull requests. `assembleRelease` runs R8 with minification and resource shrinking and is the most expensive task here; the published artifact comes from release.yml, and main still builds it on every merge, so the coverage is kept. - Cancel superseded pull request runs. android.yml had no `concurrency` block, so all four pushes to #84 ran to completion, three of them pointlessly. Verified `testDebugUnitTest`, `lintDebug`, `detekt`, `spotlessCheck` locally with the new Gradle settings, and both workflow files parse. The CI timings themselves can only be confirmed by the runs this change produces. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba --- .github/workflows/android.yml | 19 +++++++++++++++++-- .github/workflows/ui-screenshots.yml | 15 ++++++++++++++- gradle.properties | 6 ++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 50c189fb..1df6bfb4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,6 +8,12 @@ on: permissions: contents: read +# Superseded pushes to a pull request cancel their in-flight run; pushes to main always +# finish so the branch keeps a complete history of results. +concurrency: + group: android-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test-and-build: runs-on: ubuntu-latest @@ -58,8 +64,16 @@ jobs: - name: Static analysis (detekt + spotless) run: ./gradlew detekt spotlessCheck - - name: Test, lint and build APKs - run: ./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug :app:assembleRelease + - name: Test, lint and build debug APK + run: ./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug + + # assembleRelease runs R8 with minification and resource shrinking, which is the single + # most expensive task in this workflow. The published artifact is built by release.yml, + # so pull requests only need it to prove the release build still compiles — which main + # covers on every merge. + - name: Build release APK + if: github.event_name == 'push' + run: ./gradlew :app:assembleRelease - name: Generate coverage report run: ./gradlew :app:jacocoTestReport @@ -79,6 +93,7 @@ jobs: if-no-files-found: error - name: Upload unsigned release APK + if: github.event_name == 'push' uses: actions/upload-artifact@v4 with: name: opencode-android-release-unsigned diff --git a/.github/workflows/ui-screenshots.yml b/.github/workflows/ui-screenshots.yml index fa1b2aa0..ef361e6a 100644 --- a/.github/workflows/ui-screenshots.yml +++ b/.github/workflows/ui-screenshots.yml @@ -1,6 +1,16 @@ name: Emulator UI Screenshots on: + # Also on main, not because the screenshots are needed there but because + # gradle/actions/setup-gradle only writes its cache from the default branch, and its cache + # key is per job. Without a run on main this job has never had a warm Gradle cache and has + # recompiled the whole app from scratch on every pull request. + push: + branches: [main] + paths: + - 'app/src/main/**' + - 'app/src/androidTest/**' + - '.github/workflows/ui-screenshots.yml' pull_request: paths: - 'app/src/main/**' @@ -18,7 +28,10 @@ permissions: jobs: capture: - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + if: >- + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-22.04 timeout-minutes: 60 diff --git a/gradle.properties b/gradle.properties index 28fb368e..b8a80c72 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,3 +4,9 @@ android.enableJetifier=false android.suppressUnsupportedCompileSdk=35 kotlin.code.style=official org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# CI spends almost all of its time recompiling from scratch. The build cache lets a run reuse +# task outputs produced by an earlier run (persisted from main by gradle/actions/setup-gradle), +# which is what makes the second and later builds of the same commit range cheap. +org.gradle.caching=true +org.gradle.parallel=true From a961a7f203051148e72d6fbc67400b79c91d6f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 11:54:57 +0000 Subject: [PATCH 2/2] ci: drop the emulator screenshot workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is the most expensive thing in CI (17 minutes per pull request) and it was not buying a proportional amount. It never compared against golden images — the PNGs were only uploaded as an artifact for a human to look at, and the one automated check was a hand-maintained list of Japanese strings expected to be on screen. That list drifts. `5e290b1` removed the composer's workspace chip on 23 July and the assertion for it stayed, so the job failed on #80, #81, #82 and #83 — all merged with it red — until #84 repaired three separate stale assertions. And because the assertions run before the capture, a single stale string took the render smoke test and the screenshots down with it: for two days the job was pure cost. Removes the workflow along with `UiScreenshotInstrumentedTest` and the `PreviewRuntimeTarget` fixture that only it used. The other instrumented tests (`ChatFlowE2ETest`, `ChatVoiceInstrumentedTest`, `LocalRuntimeUpdaterInstrumentedTest`) are untouched. What is lost: nothing checks that the ten main screens still render on a real Android runtime. If that coverage is wanted back, it should return as a render check with tag-based assertions rather than screen-specific wording, so it cannot rot the same way. Verified `compileDebugAndroidTestKotlin`, `testDebugUnitTest`, `detekt` and `spotlessCheck` after the removal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba --- .github/workflows/ui-screenshots.yml | 246 ----------- .../android/ui/PreviewRuntimeTarget.kt | 66 --- .../ui/UiScreenshotInstrumentedTest.kt | 394 ------------------ 3 files changed, 706 deletions(-) delete mode 100644 .github/workflows/ui-screenshots.yml delete mode 100644 app/src/androidTest/java/com/opencode/android/ui/PreviewRuntimeTarget.kt delete mode 100644 app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt diff --git a/.github/workflows/ui-screenshots.yml b/.github/workflows/ui-screenshots.yml deleted file mode 100644 index ef361e6a..00000000 --- a/.github/workflows/ui-screenshots.yml +++ /dev/null @@ -1,246 +0,0 @@ -name: Emulator UI Screenshots - -on: - # Also on main, not because the screenshots are needed there but because - # gradle/actions/setup-gradle only writes its cache from the default branch, and its cache - # key is per job. Without a run on main this job has never had a warm Gradle cache and has - # recompiled the whole app from scratch on every pull request. - push: - branches: [main] - paths: - - 'app/src/main/**' - - 'app/src/androidTest/**' - - '.github/workflows/ui-screenshots.yml' - pull_request: - paths: - - 'app/src/main/**' - - 'app/src/androidTest/**' - - '.github/workflows/ui-screenshots.yml' - workflow_dispatch: - -concurrency: - group: ui-screenshots-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - capture: - if: >- - github.event_name == 'workflow_dispatch' || - github.event_name == 'push' || - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-22.04 - timeout-minutes: 60 - - steps: - - name: Checkout PR branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - fetch-depth: 0 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Enable KVM - shell: bash - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Capture API 34 emulator screenshots - id: emulator - continue-on-error: true - shell: bash - run: | - set -Eeuo pipefail - export SCREENSHOT_OUTPUT_DIR="$RUNNER_TEMP/ui-screenshots" - exec > "$GITHUB_WORKSPACE/ui-screenshot-test.log" 2>&1 - - AVD_NAME='opencode-ui-api34' - SYSTEM_IMAGE='system-images;android-34;google_apis;x86_64' - SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" - export ANDROID_AVD_HOME="$HOME/.android/avd" - EMULATOR_PID='' - EXPECTED_IMAGES=( - 01-chat-empty.png - 02-runtime-not-ready.png - 03-drawer.png - 04-settings.png - 05-onboarding.png - 06-android-setup.png - 07-remote-connection.png - 08-model-runtime-picker.png - 09-provider-settings.png - 10-voice-settings.png - ) - - wait_for_android() { - timeout 300 adb wait-for-device - timeout 600 bash -c ' - until \ - [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d "\r")" = "1" ] && \ - adb shell service check activity 2>/dev/null | grep -q "found" && \ - adb shell service check package 2>/dev/null | grep -q "found" && \ - adb shell cmd settings get global window_animation_scale >/dev/null 2>&1 - do - sleep 5 - done - ' - } - - cleanup() { - adb emu kill 2>/dev/null || true - if test -n "$EMULATOR_PID"; then - kill "$EMULATOR_PID" 2>/dev/null || true - fi - } - trap cleanup EXIT - - yes | sdkmanager --licenses >/dev/null 2>&1 || true - sdkmanager --install 'platform-tools' 'emulator' "$SYSTEM_IMAGE" \ - > "$GITHUB_WORKSPACE/sdk-install.log" 2>&1 - - mkdir -p "$ANDROID_AVD_HOME" - printf 'no\n' | avdmanager create avd \ - --force \ - --name "$AVD_NAME" \ - --package "$SYSTEM_IMAGE" \ - --device 'pixel_6' \ - > "$GITHUB_WORKSPACE/avd-create.log" 2>&1 - "$SDK_ROOT/emulator/emulator" -list-avds | grep -Fxq "$AVD_NAME" - - "$SDK_ROOT/emulator/emulator" \ - -avd "$AVD_NAME" \ - -no-window \ - -gpu swiftshader_indirect \ - -noaudio \ - -no-boot-anim \ - -camera-back none \ - -no-snapshot-save \ - > "$GITHUB_WORKSPACE/emulator.log" 2>&1 & - EMULATOR_PID=$! - - wait_for_android - adb shell input keyevent 82 || true - adb shell settings put global window_animation_scale 0 - adb shell settings put global transition_animation_scale 0 - adb shell settings put global animator_duration_scale 0 - - ./gradlew assembleDebug assembleDebugAndroidTest - APP_APK='app/build/outputs/apk/debug/app-debug.apk' - TEST_APK='app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk' - test -s "$APP_APK" - test -s "$TEST_APK" - adb install -r "$APP_APK" - adb install -r "$TEST_APK" - adb shell pm grant com.opencode.android android.permission.POST_NOTIFICATIONS - - adb shell am instrument -w -r \ - -e class com.opencode.android.ui.UiScreenshotInstrumentedTest \ - com.opencode.android.test/androidx.test.runner.AndroidJUnitRunner \ - | tee "$GITHUB_WORKSPACE/instrumentation.log" - grep -q '^OK (1 test)' "$GITHUB_WORKSPACE/instrumentation.log" - - mkdir -p "$SCREENSHOT_OUTPUT_DIR" - for image in "${EXPECTED_IMAGES[@]}"; do - adb exec-out run-as com.opencode.android cat "files/ui-screenshots/$image" \ - > "$SCREENSHOT_OUTPUT_DIR/$image" - done - - python3 - <<'PY' - from pathlib import Path - import os - import struct - - expected = [ - '01-chat-empty.png', - '02-runtime-not-ready.png', - '03-drawer.png', - '04-settings.png', - '05-onboarding.png', - '06-android-setup.png', - '07-remote-connection.png', - '08-model-runtime-picker.png', - '09-provider-settings.png', - '10-voice-settings.png', - ] - directory = Path(os.environ['SCREENSHOT_OUTPUT_DIR']) - for name in expected: - data = (directory / name).read_bytes() - if not data.startswith(b'\x89PNG\r\n\x1a\n'): - raise SystemExit(f'{name}: invalid PNG signature: {data[:80]!r}') - if len(data) < 10_000: - raise SystemExit(f'{name}: suspiciously small ({len(data)} bytes)') - width, height = struct.unpack('>II', data[16:24]) - if width < 720 or height < 1200: - raise SystemExit(f'{name}: unexpected dimensions {width}x{height}') - print(f'{name}: {width}x{height}, {len(data)} bytes') - PY - - - name: Upload screenshot diagnostics - if: steps.emulator.outcome == 'failure' - uses: actions/upload-artifact@v4 - with: - name: emulator-ui-diagnostics-${{ github.run_id }} - path: | - instrumentation.log - ui-screenshot-test.log - emulator.log - sdk-install.log - avd-create.log - if-no-files-found: warn - retention-days: 14 - - - name: Report screenshot failure - if: steps.emulator.outcome == 'failure' - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - shell: bash - run: | - { - echo '## API 34 emulator screenshot failure' - echo - echo 'Diagnostic logs were uploaded as the `emulator-ui-diagnostics-${{ github.run_id }}` workflow artifact.' - echo - echo '```text' - if test -s instrumentation.log; then - cat instrumentation.log - else - tr '\r' '\n' < ui-screenshot-test.log 2>/dev/null | tail -n 80 || true - fi - echo '```' - } > /tmp/ui-screenshot-failure.md - if test -n "$PR_NUMBER"; then - gh pr comment "$PR_NUMBER" --body-file /tmp/ui-screenshot-failure.md - fi - exit 1 - - - name: Upload emulator screenshots - if: steps.emulator.outcome == 'success' - uses: actions/upload-artifact@v4 - with: - name: emulator-ui-screenshots-${{ github.run_id }} - path: ${{ runner.temp }}/ui-screenshots/*.png - if-no-files-found: error - retention-days: 14 diff --git a/app/src/androidTest/java/com/opencode/android/ui/PreviewRuntimeTarget.kt b/app/src/androidTest/java/com/opencode/android/ui/PreviewRuntimeTarget.kt deleted file mode 100644 index c54f0015..00000000 --- a/app/src/androidTest/java/com/opencode/android/ui/PreviewRuntimeTarget.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.opencode.android.ui - -import com.opencode.android.core.api.OpenCodeAgent -import com.opencode.android.core.api.OpenCodeEvent -import com.opencode.android.core.api.OpenCodeHealth -import com.opencode.android.core.api.OpenCodeMessage -import com.opencode.android.core.api.OpenCodeSession -import com.opencode.android.core.api.PromptRequest -import com.opencode.android.core.api.ProviderCatalog -import com.opencode.android.runtime.BackendKind -import com.opencode.android.runtime.PermissionResponse -import com.opencode.android.runtime.RuntimeState -import com.opencode.android.runtime.RuntimeTarget -import com.opencode.android.runtime.RuntimeType -import com.opencode.android.runtime.WorkspaceRef -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emptyFlow - -internal class PreviewRuntimeTarget( - override val id: String, - override val displayName: String, - override val type: RuntimeType, -) : RuntimeTarget { - override val kind: BackendKind = if (type == RuntimeType.LOCAL) BackendKind.LOCAL else BackendKind.REMOTE - private val mutableState = MutableStateFlow(RuntimeState.Connected("1.0.0")) - override val state: StateFlow = mutableState - - override suspend fun connect(): Result = Result.success(health()) - - override fun disconnect() = Unit - - override suspend fun listWorkspaces(): List = emptyList() - - override suspend fun health(): OpenCodeHealth = OpenCodeHealth(healthy = true, version = "1.0.0") - - override suspend fun listSessions(directory: String?): List = emptyList() - - override suspend fun createSession( - title: String?, - directory: String?, - ): OpenCodeSession = OpenCodeSession(id = "preview", title = title.orEmpty(), directory = directory) - - override suspend fun listMessages(sessionId: String): List = emptyList() - - override suspend fun listProviders(): ProviderCatalog = ProviderCatalog() - - override suspend fun listAgents(): List = emptyList() - - override suspend fun sendMessage( - sessionId: String, - request: PromptRequest, - ) = Unit - - override suspend fun abortSession(sessionId: String): Boolean = true - - override suspend fun respondToPermission( - sessionId: String, - permissionId: String, - response: PermissionResponse, - remember: Boolean, - ): Boolean = true - - override fun events(): Flow = emptyFlow() -} diff --git a/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt b/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt deleted file mode 100644 index 3027d39b..00000000 --- a/app/src/androidTest/java/com/opencode/android/ui/UiScreenshotInstrumentedTest.kt +++ /dev/null @@ -1,394 +0,0 @@ -package com.opencode.android.ui - -import android.content.res.Configuration -import android.graphics.Bitmap -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Surface -import androidx.compose.material3.rememberDrawerState -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -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.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import androidx.test.platform.app.InstrumentationRegistry -import com.opencode.android.MainActivity -import com.opencode.android.core.api.OpenCodeAgent -import com.opencode.android.core.api.OpenCodeHealth -import com.opencode.android.core.api.OpenCodeModel -import com.opencode.android.core.api.OpenCodeProvider -import com.opencode.android.feature.chat.ChatHomeScreen -import com.opencode.android.feature.chat.ChatUiState -import com.opencode.android.feature.chat.ModelAndRuntimePickerSheet -import com.opencode.android.feature.onboarding.AndroidSetupScreen -import com.opencode.android.feature.onboarding.OnboardingChoiceScreen -import com.opencode.android.feature.settings.ProviderSettingsScreen -import com.opencode.android.feature.settings.SettingsScreenV2 -import com.opencode.android.feature.settings.SettingsUiState -import com.opencode.android.feature.settings.VoiceSettingsScreen -import com.opencode.android.feature.workspace.RemoteConnectionScreen -import com.opencode.android.runtime.LocalRuntimeStatus -import com.opencode.android.runtime.RuntimeType -import com.opencode.android.runtime.WorkspaceRef -import com.opencode.android.ui.components.SessionStatus -import com.opencode.android.ui.theme.OpenCodeAndroidTheme -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import java.io.File -import java.io.FileOutputStream -import java.util.Locale - -@RunWith(AndroidJUnit4::class) -@LargeTest -class UiScreenshotInstrumentedTest { - @get:Rule - val composeRule = createAndroidComposeRule() - - private val previewAgents = listOf(OpenCodeAgent(name = "build")) - private val previewWorkspaces = - listOf( - WorkspaceRef( - id = "/workspace/project", - name = "project", - path = "/workspace/project", - ), - ) - private val previewProviders = - listOf( - OpenCodeProvider( - id = "zai", - name = "Z.ai", - models = - linkedMapOf( - "glm-5" to - OpenCodeModel( - id = "glm-5", - providerId = "zai", - name = "GLM-5", - ), - "glm-4.5" to - OpenCodeModel( - id = "glm-4.5", - providerId = "zai", - name = "GLM-4.5", - ), - ), - ), - ) - private val previewRuntimeTargets = - listOf( - PreviewRuntimeTarget("local", "このAndroid", RuntimeType.LOCAL), - PreviewRuntimeTarget("home-mac", "自宅のMacBook", RuntimeType.REMOTE), - ) - - @OptIn(ExperimentalMaterial3Api::class) - @Test - fun captureReviewedScreens() { - useJapaneseResources() - - capture("01-chat-empty", { - composeRule.onNodeWithText("チャット").assertIsDisplayed() - composeRule.onNodeWithText("GLM-5").assertIsDisplayed() - // The composer button row is attach / model / thinking … mic / send since the - // paseo redesign (5e290b1); the workspace chip it used to carry is gone, so this - // asserts the composer itself rather than a chip that no longer exists. - composeRule.onNodeWithText("OpenCodeにメッセージを送る…").assertIsDisplayed() - }) { PreviewChatHome() } - - capture("02-runtime-not-ready", { - composeRule.onNodeWithText("このAndroidをセットアップ").assertIsDisplayed() - composeRule.onNodeWithText("PC・Macに接続").assertIsDisplayed() - check( - composeRule.onAllNodesWithText("OpenCodeにメッセージを送る…") - .fetchSemanticsNodes().isEmpty(), - ) { "Composer must be hidden while the runtime is not ready" } - }) { - PreviewChatHome( - state = ChatUiState(error = "Android local OpenCode runtime is not installed"), - ) - } - - capture("03-drawer", { - composeRule.onNodeWithText("最近のチャット").assertIsDisplayed() - composeRule.onNodeWithText("設定").assertIsDisplayed() - }) { - val drawerState = rememberDrawerState(initialValue = DrawerValue.Open) - ModalNavigationDrawer( - drawerState = drawerState, - gesturesEnabled = false, - drawerContent = { - ModalDrawerSheet { - AppDrawerContent( - recentSessions = - listOf( - DrawerRecentSession( - "1", - "認証エラーの調査", - "3時間前", - status = SessionStatus.RUNNING, - ), - DrawerRecentSession( - "2", - "READMEを更新", - "昨日", - status = SessionStatus.COMPLETED_UNREAD, - ), - DrawerRecentSession("3", "テスト失敗を修正", "2日前"), - DrawerRecentSession("4", "APIレスポンスを整理", "4日前"), - DrawerRecentSession("5", "依存関係を更新", "1週間前"), - ), - workspaces = - listOf( - WorkspaceRef("1", "project", "/workspace/project"), - ), - selectedWorkspacePath = "/workspace/project", - onNewChat = {}, - onSelectProject = {}, - onOpenSession = { _, _ -> }, - onNavigate = {}, - ) - } - }, - ) { PreviewChatHome() } - } - - capture("04-settings", { - composeRule.onNodeWithText("設定").assertIsDisplayed() - composeRule.onNodeWithText("ウェイクワード").assertIsDisplayed() - }) { - SettingsScreenV2( - assistantConfigured = true, - notificationsEnabled = true, - onToggleNotifications = {}, - appVersion = "0.2.0", - onOpenDrawer = {}, - onOpenAssistantSettings = {}, - onOpenVoiceSettings = {}, - onOpenProviderSettings = {}, - onOpenLocalRuntime = {}, - onOpenRemoteConnection = {}, - onOpenWorkspaces = {}, - onOpenDiagnostics = {}, - ) - } - - capture("05-onboarding", { - composeRule.onNodeWithText("OpenCodeへようこそ").assertIsDisplayed() - composeRule.onNodeWithText("このAndroidで始める").assertIsDisplayed() - composeRule.onNodeWithText("PC・Macに接続する").assertIsDisplayed() - }) { - OnboardingChoiceScreen( - onSelectAndroid = {}, - onSelectRemote = {}, - onAddRemoteLater = {}, - ) - } - - capture("06-android-setup", { - composeRule.onNodeWithText("このAndroidをセットアップ").assertIsDisplayed() - composeRule.onNodeWithText("ランタイムをダウンロード").assertIsDisplayed() - composeRule.onNodeWithText("ランタイムをダウンロード中").assertIsDisplayed() - }) { - AndroidSetupScreen( - runtimeStatus = - LocalRuntimeStatus.Installing( - progress = 0.68f, - step = "ランタイムをダウンロード中", - ), - onStartRuntimeSetup = {}, - settingsState = com.opencode.android.feature.settings.SettingsUiState(), - onOpenProviderAuth = {}, - onSelectProviderAuthMethod = {}, - onProviderAuthInput = { _, _ -> }, - onProviderApiKey = {}, - onSubmitProviderAuth = {}, - onCompleteProviderOAuth = {}, - onDisconnectProvider = {}, - onDismissProviderAuth = {}, - onRefreshProviderAuth = {}, - onRefreshCatalog = {}, - onBack = {}, - onFinish = {}, - ) - } - - capture("07-remote-connection", { - composeRule.onNodeWithText("PC・Macに接続").assertIsDisplayed() - composeRule.onNodeWithText("接続をテスト").assertIsDisplayed() - }) { - RemoteConnectionScreen( - onTestConnection = { Result.success(OpenCodeHealth(true, "1.0.0")) }, - onSaveConnection = {}, - onBack = {}, - onConnected = {}, - ) - } - - capture("08-model-runtime-picker", { - composeRule.onNodeWithText("実行先").assertIsDisplayed() - composeRule.onNodeWithText("このAndroid").assertIsDisplayed() - composeRule.onNodeWithText("自宅のMacBook").assertIsDisplayed() - check(composeRule.onAllNodesWithText("GLM-5").fetchSemanticsNodes().isNotEmpty()) { - "Model picker must display GLM-5" - } - }) { - Box(modifier = Modifier.fillMaxSize()) { - PreviewChatHome() - ModelAndRuntimePickerSheet( - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - runtimeTargets = previewRuntimeTargets, - selectedRuntimeId = "local", - onSelectRuntime = {}, - providers = previewProviders, - selectedProviderId = "zai", - selectedModelId = "glm-5", - onSelectModel = { _, _ -> }, - onDismiss = {}, - ) - } - } - - capture("09-provider-settings", { - composeRule.onNodeWithText("プロバイダ設定").assertIsDisplayed() - // The screen is now a search field over provider rows; the old - // "saved credentials" / "add API key" sections are gone and their string - // resources are orphaned. - composeRule.onNodeWithText("プロバイダを検索…").assertIsDisplayed() - composeRule.onNodeWithText("Z.ai").assertIsDisplayed() - }) { - ProviderSettingsScreen( - state = - SettingsUiState( - availableProviders = previewProviders, - credentialStatuses = linkedMapOf("openai" to true, "anthropic" to false), - ), - onOpenProviderAuth = {}, - onSelectProviderAuthMethod = {}, - onProviderAuthInput = { _, _ -> }, - onProviderApiKey = {}, - onSubmitProviderAuth = {}, - onCompleteProviderOAuth = {}, - onDisconnectProvider = {}, - onLaunchOAuthBrowser = {}, - onDismissProviderAuth = {}, - onBack = {}, - ) - } - - capture("10-voice-settings", { - composeRule.onNodeWithText("音声設定").assertIsDisplayed() - composeRule.onNodeWithText("ウェイクワード").assertIsDisplayed() - // The "extra pack not installed" notice was dropped along with its string - // resource; the row now carries the invocation description instead. - composeRule.onNodeWithText("「Hey Mycroft」でハンズフリーでアシスタントを起動します。") - .assertIsDisplayed() - }) { - VoiceSettingsScreen( - ttsEnabled = true, - continuousConversation = false, - wakeWordEnabled = false, - onTtsChange = {}, - onContinuousChange = {}, - onWakeWordChange = {}, - onBack = {}, - ) - } - } - - @Composable - private fun PreviewChatHome( - state: ChatUiState = - ChatUiState( - isConnected = true, - selectedWorkspacePath = "/workspace/project", - ), - ) { - ChatHomeScreen( - state = state, - providers = previewProviders, - agents = previewAgents, - workspaces = previewWorkspaces, - selectedProviderId = "zai", - selectedModelId = "GLM-5", - selectedAgentId = "build", - runtimeTargets = previewRuntimeTargets, - selectedRuntimeId = "local", - onSelectRuntime = {}, - onSelectModel = { _, _ -> }, - onSelectAgent = {}, - onSelectWorkspace = {}, - onSelectQuestionAnswer = { _, _, _ -> }, - onSubmitQuestion = {}, - onSendMessage = {}, - onPermission = { _, _, _ -> }, - onAbort = {}, - onMic = {}, - onNewChat = {}, - onOpenHistory = {}, - onOpenLocalSetup = {}, - onOpenRemoteSetup = {}, - onOpenDrawer = {}, - ) - } - - @Suppress("DEPRECATION") - private fun useJapaneseResources() { - composeRule.activity.runOnUiThread { - Locale.setDefault(Locale.JAPAN) - val resources = composeRule.activity.resources - val configuration = - Configuration(resources.configuration).apply { - setLocale(Locale.JAPAN) - } - resources.updateConfiguration(configuration, resources.displayMetrics) - } - composeRule.waitForIdle() - } - - private fun capture( - name: String, - assertions: () -> Unit, - content: @Composable () -> Unit, - ) { - composeRule.activity.setContent { - OpenCodeAndroidTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - ) { content() } - } - } - composeRule.waitForIdle() - InstrumentationRegistry.getInstrumentation().waitForIdleSync() - assertions() - Thread.sleep(400) - - val bitmap = - requireNotNull( - InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot(), - ) { "Unable to capture emulator screenshot: $name" } - val directory = - File( - InstrumentationRegistry.getInstrumentation().targetContext.filesDir, - "ui-screenshots", - ).apply { mkdirs() } - FileOutputStream(File(directory, "$name.png")).use { output -> - check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) { - "Unable to encode emulator screenshot: $name" - } - } - bitmap.recycle() - } -}