Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6f12a94
test: Add Espresso Web and Compose UI Test dependencies for Android E2E
dcalhoun Feb 26, 2026
36738f4
test: Add Android E2E tests for editor loading and undo/redo
dcalhoun Feb 26, 2026
567c7c7
test: Add Makefile targets for Android E2E tests
dcalhoun Feb 26, 2026
ad9d5a7
fix: Always replace Android assets before E2E tests
dcalhoun Feb 26, 2026
38a0228
fix: Restore emulator animations after E2E tests
dcalhoun Feb 26, 2026
5292eaa
fix: Replace class-based selectors with aria attributes in Android E2…
dcalhoun Feb 26, 2026
47b2370
fix: Replace Kotlin assert() with JUnit Assert methods in Android E2E…
dcalhoun Feb 26, 2026
05766c8
fix: Quote $ANDROID_HOME paths in ENSURE_ANDROID_DEVICE macro
dcalhoun Feb 26, 2026
032da28
refactor: Move Compose test dependencies to version catalog
dcalhoun Feb 26, 2026
f3d53b3
refactor: Extract waitUntil/runCatching pattern into extension functions
dcalhoun Feb 26, 2026
489fb33
refactor: Simplify clickViaJs to use el.click() instead of full event…
dcalhoun Feb 26, 2026
af9ffd0
refactor: Consolidate WebView element waiting into single JS-based ap…
dcalhoun Feb 26, 2026
163a35b
refactor: Use Espresso Web XPath locator for block insertion
dcalhoun Feb 26, 2026
92d00ae
refactor: Extract inline inserter dialog selectors into named values
dcalhoun Feb 26, 2026
80230a9
refactor: Extract runJs() helper to deduplicate WebView JS execution
dcalhoun Feb 26, 2026
28b4574
fix: Narrow catch (Throwable) to catch (Exception) in waitForConditio…
dcalhoun Feb 26, 2026
2207813
refactor: Use Kotlin multiline strings for JS snippets
dcalhoun Feb 26, 2026
2f893da
fix: Add boot timeout to ENSURE_ANDROID_DEVICE macro
dcalhoun Feb 26, 2026
dc806d0
fix: Default to 1.0 for missing animation settings in DisableAnimatio…
dcalhoun Feb 26, 2026
c93c1fe
fix: Check execCommand return value in typeViaExecCommand
dcalhoun Feb 26, 2026
0ecc1cb
refactor: Simplify readTextViaJs to only handle textarea elements
dcalhoun Feb 26, 2026
905ab77
fix: Kill emulator process on boot timeout in ENSURE_ANDROID_DEVICE
dcalhoun Feb 26, 2026
a1cd141
refactor: Add EditorTestRule type alias for verbose compose rule type
dcalhoun Feb 26, 2026
576d812
refactor: Replace JS workarounds with standard Espresso Web APIs
dcalhoun Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,62 @@ test-android: ## Run Android tests
@echo "--- :android: Running Android Tests"
./android/gradlew -p ./android :gutenberg:test

# Ensure an Android device or emulator is available for instrumented tests.
# Checks for any connected device; if none found, boots the first available AVD.
define ENSURE_ANDROID_DEVICE
@if adb devices 2>/dev/null | tail -n +2 | grep -q 'device$$'; then \
echo "--- :white_check_mark: Android device already connected."; \
else \
AVD=$$("$$ANDROID_HOME/emulator/emulator" -list-avds 2>/dev/null | head -n 1); \
if [ -z "$$AVD" ]; then \
echo "Error: No Android device connected and no AVDs found."; \
echo "Connect a device, start an emulator, or create an AVD with Android Studio."; \
exit 1; \
fi; \
echo "--- :rocket: Booting Android emulator ($$AVD)..."; \
"$$ANDROID_HOME/emulator/emulator" -avd "$$AVD" -no-snapshot-load -no-audio -no-window &>/dev/null & \
EMULATOR_PID=$$!; \
echo "--- :hourglass: Waiting for emulator to boot..."; \
adb wait-for-device; \
BOOT_WAIT=0; \
while [ "$$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do \
BOOT_WAIT=$$((BOOT_WAIT + 1)); \
if [ $$BOOT_WAIT -gt 60 ]; then \
echo "Error: Emulator boot timed out after 120 seconds."; \
kill $$EMULATOR_PID 2>/dev/null; \
exit 1; \
fi; \
sleep 2; \
done; \
echo "--- :white_check_mark: Emulator booted."; \
fi
endef

.PHONY: test-android-e2e
test-android-e2e: ## Run Android E2E tests against the production build
@if [ ! -d "dist" ]; then \
$(MAKE) build; \
else \
echo "--- :white_check_mark: Using existing build. Use 'make build REFRESH_JS_BUILD=1' to rebuild."; \
fi
@echo "--- :open_file_folder: Copying build into Android bundle"
@rm -rf ./android/Gutenberg/src/main/assets/
@cp -r ./dist/. ./android/Gutenberg/src/main/assets
$(ENSURE_ANDROID_DEVICE)
@echo "--- :android: Running Android E2E Tests (production build)"
./android/gradlew -p ./android :app:connectedDebugAndroidTest

.PHONY: test-android-e2e-dev
test-android-e2e-dev: ## Run Android E2E tests against the Vite dev server (must be running)
@if ! curl -sf http://localhost:5173 > /dev/null 2>&1; then \
echo "Error: Dev server is not running at http://localhost:5173"; \
echo "Start it first with: make dev-server"; \
exit 1; \
fi
$(ENSURE_ANDROID_DEVICE)
@echo "--- :android: Running Android E2E Tests (dev server)"
./android/gradlew -p ./android :app:connectedDebugAndroidTest

################################################################################
# Release Target
################################################################################
Expand Down
4 changes: 4 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,8 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.web)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.example.gutenbergkit

import android.os.ParcelFileDescriptor
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

/**
* JUnit rule that disables device animations before each test and
* restores the original values afterward.
*
* Unlike `testOptions { animationsDisabled = true }` in Gradle, this
* approach does not permanently alter emulator settings — animations
* are restored in the `finally` block even if the test fails.
*/
class DisableAnimationsRule : TestRule {

private companion object {
val ANIMATION_SETTINGS = listOf(
"window_animation_scale",
"transition_animation_scale",
"animator_duration_scale"
)
}

override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
val uiAutomation = InstrumentationRegistry
.getInstrumentation()
.uiAutomation

val originalValues = ANIMATION_SETTINGS.map { setting ->
val value = executeShellCommand(uiAutomation, "settings get global $setting")
// Default to 1.0 when the setting doesn't exist on the device
// (adb returns the string "null" for missing settings).
setting to if (value == "null") "1.0" else value
}

try {
for (setting in ANIMATION_SETTINGS) {
executeShellCommand(uiAutomation, "settings put global $setting 0")
}
base.evaluate()
} finally {
for ((setting, value) in originalValues) {
executeShellCommand(uiAutomation, "settings put global $setting $value")
}
}
}
}
}

private fun executeShellCommand(
uiAutomation: android.app.UiAutomation,
command: String
): String {
val pfd: ParcelFileDescriptor = uiAutomation.executeShellCommand(command)
return pfd.use {
ParcelFileDescriptor.AutoCloseInputStream(it)
.bufferedReader()
.readText()
.trim()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.example.gutenbergkit

import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* E2E tests for editor interactions via the native Android UI layer.
*
* These tests verify that user actions in the native shell (toolbar
* taps, navigation) correctly propagate through the WebView bridge
* to the Gutenberg editor and back.
*
* Unlike Playwright (which injects `window.GBKit` directly via
* `addInitScript`), these tests exercise the real native configuration
* pipeline: `EditorConfiguration` → `GutenbergView` →
* JS bridge injection → Gutenberg JS initialization.
*
* Mirrors `EditorInteractionUITest` on iOS.
*/
@RunWith(AndroidJUnit4::class)
class EditorInteractionTest {

@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()

@get:Rule
val disableAnimationsRule = DisableAnimationsRule()

// -- Editor Loading --

/**
* A WebView becomes visible after the editor finishes loading.
*/
@Test
fun testEditorWebViewBecomesVisible() {
EditorTestHelpers.navigateToEditor(composeTestRule)
}

// -- Editor History --

/**
* Typing in the title and content enables undo; tapping undo enables redo.
*
* Exercises the full bridge round-trip:
* 1. Verify undo/redo are disabled on a fresh editor
* 2. Type text in title → bridge sends `onEditorHistoryChanged` with `hasUndo: true`
* 3. Insert Paragraph block, type in content → undo remains enabled
* 4. Tap Undo → native calls `undo()` on GutenbergView → redo enables
* 5. Tap Redo → redo disables, undo re-enables
*/
@Test
fun testUndoRedoAfterTyping() {
EditorTestHelpers.navigateToEditor(composeTestRule)

// On a fresh editor, both buttons should be disabled.
EditorTestHelpers.assertDisabled(composeTestRule, "Undo")
EditorTestHelpers.assertDisabled(composeTestRule, "Redo")

// Type in the title field.
EditorTestHelpers.typeInTitle("Hello")

// After typing in the title, undo should become enabled.
EditorTestHelpers.waitForEnabled(composeTestRule, "Undo")

// Insert a Paragraph block and type in the content area.
EditorTestHelpers.typeInContent("World")

// Undo should still be enabled after typing content.
EditorTestHelpers.waitForEnabled(composeTestRule, "Undo")

// Verify content before undo.
EditorTestHelpers.assertContent(
expectedTitle = "Hello",
expectedContentSubstring = "World",
rule = composeTestRule
)

// Tap undo — redo should become enabled.
composeTestRule.onNodeWithContentDescription("Undo").performClick()
EditorTestHelpers.waitForEnabled(composeTestRule, "Redo")

// Verify the last typed text was undone.
val afterUndo = EditorTestHelpers.readTitleAndContent(composeTestRule)
assertFalse(
"Content should not contain undone text but got \"${afterUndo.content}\"",
afterUndo.content.contains("World")
)

// Tap redo — undo should remain enabled.
composeTestRule.onNodeWithContentDescription("Redo").performClick()
EditorTestHelpers.waitForEnabled(composeTestRule, "Undo")

// Verify content is restored after redo.
EditorTestHelpers.assertContent(
expectedTitle = "Hello",
expectedContentSubstring = "World",
rule = composeTestRule
)
}
}
Loading
Loading