diff --git a/Makefile b/Makefile index 0f6b89a76..d6811a63c 100644 --- a/Makefile +++ b/Makefile @@ -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 ################################################################################ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f49214aa..ea87db1e5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) } diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt new file mode 100644 index 000000000..b8424278a --- /dev/null +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt @@ -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() + } + } +} diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt new file mode 100644 index 000000000..7c6491648 --- /dev/null +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt @@ -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() + + @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 + ) + } +} diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt new file mode 100644 index 000000000..e26c993fb --- /dev/null +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -0,0 +1,334 @@ +package com.example.gutenbergkit + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.espresso.web.model.Atoms.script +import androidx.test.espresso.web.sugar.Web.onWebView +import androidx.test.espresso.web.webdriver.DriverAtoms.findElement +import androidx.test.espresso.web.webdriver.DriverAtoms.webClick +import androidx.test.espresso.web.webdriver.Locator +import androidx.test.ext.junit.rules.ActivityScenarioRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue + +typealias EditorTestRule = AndroidComposeTestRule, MainActivity> + +/** + * Reusable helpers for Android E2E tests that interact with the Gutenberg editor. + * + * All methods are on a companion-style object so they can be called from any + * test file — e.g. `EditorTestHelpers.navigateToEditor(rule)`. + * + * Mirrors `EditorUITestHelpers` on iOS. + */ +object EditorTestHelpers { + + private const val NAVIGATE_TIMEOUT_MS = 30_000L + private const val ELEMENT_TIMEOUT_MS = 10_000L + + // CSS selectors matching the Gutenberg DOM — prefer aria attributes and + // placeholders over class names so tests are resilient to CSS refactors. + private const val TITLE_SELECTOR = "[aria-label='Add title']" + // Scope to the Editor toolbar to avoid matching the inline block appender, + // which also renders an identical "Add block" button. + private const val ADD_BLOCK_SELECTOR = + "[aria-label='Editor toolbar'] [aria-label='Add block']" + private const val EMPTY_BLOCK_SELECTOR = + "[aria-label='Empty block; start writing or type forward slash to choose a block']" + private const val CODE_EDITOR_TITLE_SELECTOR = + "textarea[placeholder='Add title']" + private const val CODE_EDITOR_CONTENT_SELECTOR = + "textarea[placeholder='Start writing with text or HTML']" + private const val INSERTER_DIALOG_SELECTOR = + "[role='dialog'][aria-modal='true']" + + /** + * Navigates from the main list through the configuration screen + * and into the full-screen editor. Waits for the "Add title" element + * in the WebView to confirm the editor has loaded. + */ + fun navigateToEditor( + rule: EditorTestRule + ) { + // Tap the "Standalone editor" card in the main list. + rule.waitForNodeWithText("Standalone editor") + rule.onNodeWithText("Standalone editor").performClick() + + // Wait for and tap the "Start" button on the configuration screen. + rule.waitForNodeWithText("Start") + rule.onNodeWithText("Start").performClick() + + // Wait for the WebView to load: poll until the title element appears. + waitForWebViewElement(TITLE_SELECTOR, NAVIGATE_TIMEOUT_MS) + } + + /** + * Types text into the title field in the WebView. + * + * Uses JavaScript keyboard event dispatch because Espresso Web's + * `webKeys()` fails on Gutenberg's contenteditable rich text blocks + * with "Cannot set the selection end". + */ + fun typeInTitle(text: String) { + onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.CSS_SELECTOR, TITLE_SELECTOR)) + .perform(webClick()) + typeViaExecCommand(text) + } + + /** + * Opens the web block inserter and inserts a block by name. + * + * Taps the "Add block" toggle in the editor toolbar, then clicks + * the block option matching [name] inside the inserter popover. + * Mirrors `EditorUITestHelpers.insertBlock(_:webView:app:)` on iOS. + */ + fun insertBlock(name: String) { + // Tap the "Add block" toggle button in the WebView toolbar. + onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.CSS_SELECTOR, ADD_BLOCK_SELECTOR)) + .perform(webClick()) + // Wait for the inserter dialog to appear, then find and click the block + // option by name. Block items use role="option" with their accessible + // name from inner text — we match via XPath within the modal dialog. + waitForWebViewElement(INSERTER_DIALOG_SELECTOR, ELEMENT_TIMEOUT_MS) + onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.XPATH, inserterOptionXpath(name))) + .perform(webClick()) + } + + /** + * Inserts a Paragraph block via the web block inserter then types + * text into the empty block placeholder. + */ + fun typeInContent(text: String) { + insertBlock("Paragraph") + // Wait for the empty block to appear after insertion. + waitForWebViewElement(EMPTY_BLOCK_SELECTOR, ELEMENT_TIMEOUT_MS) + onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.CSS_SELECTOR, EMPTY_BLOCK_SELECTOR)) + .perform(webClick()) + typeViaExecCommand(text) + } + + // -- Mode Switching -- + + /** + * Switches the editor to Code Editor mode via the More options menu. + */ + fun switchToCodeEditor( + rule: EditorTestRule + ) { + rule.onNodeWithContentDescription("More options").performClick() + rule.waitForNodeWithText("Code editor") + rule.onNodeWithText("Code editor").performClick() + } + + /** + * Switches the editor back to Visual Editor mode via the More options menu. + */ + fun switchToVisualEditor( + rule: EditorTestRule + ) { + rule.onNodeWithContentDescription("More options").performClick() + rule.waitForNodeWithText("Visual editor") + rule.onNodeWithText("Visual editor").performClick() + } + + // -- Content Reading (Code Editor Mode) -- + + /** + * Reads the current title from the code editor's title field via JS. + * The editor must already be in Code Editor mode. + */ + fun readTitle(): String { + return readTextViaJs(CODE_EDITOR_TITLE_SELECTOR) + } + + /** + * Reads the current raw HTML content from the code editor's content textarea via JS. + * The editor must already be in Code Editor mode. + */ + fun readContent(): String { + return readTextViaJs(CODE_EDITOR_CONTENT_SELECTOR) + } + + /** + * Switches to Code Editor, reads both title and content, then switches back. + * Returns a [TitleAndContent] data class. + */ + fun readTitleAndContent( + rule: EditorTestRule + ): TitleAndContent { + switchToCodeEditor(rule) + // Wait for the code editor content textarea to appear in the DOM. + waitForWebViewElement(CODE_EDITOR_CONTENT_SELECTOR, ELEMENT_TIMEOUT_MS) + val title = readTitle() + val content = readContent() + switchToVisualEditor(rule) + return TitleAndContent(title = title, content = content) + } + + /** + * Convenience assertion: switches to Code Editor, reads title and content, + * switches back, and asserts expected values. + */ + fun assertContent( + expectedTitle: String? = null, + expectedContentSubstring: String? = null, + rule: EditorTestRule + ): TitleAndContent { + val result = readTitleAndContent(rule) + if (expectedTitle != null) { + assertEquals("Title mismatch", expectedTitle, result.title) + } + if (expectedContentSubstring != null) { + assertTrue( + "Expected content to contain \"$expectedContentSubstring\" but got \"${result.content}\"", + result.content.contains(expectedContentSubstring) + ) + } + return result + } + + // -- Waiting Helpers -- + + /** + * Waits until a Compose node with the given content description becomes enabled. + */ + fun waitForEnabled( + rule: EditorTestRule, + contentDescription: String, + timeoutMs: Long = ELEMENT_TIMEOUT_MS + ) { + rule.waitUntilAsserts(timeoutMs) { + onNodeWithContentDescription(contentDescription).assertIsEnabled() + } + } + + /** + * Asserts a Compose node with the given content description is not enabled. + */ + fun assertDisabled( + rule: EditorTestRule, + contentDescription: String + ) { + rule.onNodeWithContentDescription(contentDescription).assertIsNotEnabled() + } + + // -- Internal Helpers -- + + /** + * Executes a JavaScript snippet in the WebView and returns the result as a string. + * Centralizes the Espresso Web boilerplate shared by all JS helpers. + */ + private fun runJs(js: String): String { + val result = onWebView() + .forceJavascriptEnabled() + .perform(script(js)) + .get() + return result.value?.toString() ?: "" + } + + /** + * Returns an XPath that matches a block option by [name] inside + * the inserter dialog (role="dialog", aria-modal="true"). + */ + private fun inserterOptionXpath(name: String) = + "//*[@role='dialog'][@aria-modal='true']//*[@role='option'][normalize-space()='$name']" + + /** + * Types text into the currently focused element via + * `document.execCommand('insertText')`, which is what mobile browsers + * use for software keyboard input. Gutenberg's rich text listens for + * the resulting `input` event at the contenteditable level. + * + * This bypasses Espresso Web's `webKeys()`, which fails on Gutenberg's + * contenteditable rich text blocks with "Cannot set the selection end". + */ + private fun typeViaExecCommand(text: String) { + val escapedText = text.replace("\\", "\\\\").replace("'", "\\'") + val js = """ + var result = document.execCommand('insertText', false, '$escapedText'); + return result ? 'ok' : 'execCommand failed'; + """.trimIndent() + val value = runJs(js) + if (value.contains("failed")) { + throw AssertionError("typeViaExecCommand failed: execCommand returned false") + } + } + + /** + * Reads the value of a textarea/input element by CSS selector via JS. + * Used to read Code Editor fields which render as `