From 6f12a94a1221ec068915018f9b160d7349897cdf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 11:29:41 -0500 Subject: [PATCH 01/24] test: Add Espresso Web and Compose UI Test dependencies for Android E2E Add espresso-web to the version catalog and wire up the test dependencies needed by the instrumented E2E suite: espresso-web for WebView DOM interaction, compose-ui-test-junit4 for Compose semantics assertions, and ui-test-manifest for the debug variant. Disable animations in testOptions to avoid flakiness. --- android/app/build.gradle.kts | 7 +++++++ android/gradle/libs.versions.toml | 2 ++ 2 files changed, 9 insertions(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f49214aa..8e1bf9d3a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,6 +54,9 @@ android { kotlinOptions { jvmTarget = "1.8" } + testOptions { + animationsDisabled = true + } buildFeatures { compose = true buildConfig = true @@ -83,4 +86,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("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 3f0d56c56..9473a69da 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -6,6 +6,7 @@ coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" +espressoWeb = "3.6.1" appcompat = "1.7.0" material = "1.12.0" activity = "1.9.0" @@ -27,6 +28,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-espresso-web = { group = "androidx.test.espresso", name = "espresso-web", version.ref = "espressoWeb" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } From 36738f4fcc6b351367796d2819bf98b32724c8b5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 11:29:54 -0500 Subject: [PATCH 02/24] test: Add Android E2E tests for editor loading and undo/redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add EditorTestHelpers (mirrors iOS EditorUITestHelpers) and EditorInteractionTest (mirrors iOS EditorInteractionUITest) with two test cases: - testEditorWebViewBecomesVisible: navigates Main → SitePreparation → Editor and verifies the Gutenberg WebView loads. - testUndoRedoAfterTyping: full native-to-web bridge round-trip — types in title, inserts a Paragraph block via the web inserter, types in content, then verifies Undo/Redo toolbar button state propagates correctly through the bridge. Key implementation details: - Uses document.execCommand('insertText') instead of Espresso Web's webKeys(), which fails on Gutenberg's contenteditable rich text. - Clicks WebView toolbar buttons via JS pointer/mouse event dispatch for React synthetic event compatibility. - Inserts blocks through the web block inserter (Add block → Paragraph) since Android has no native block inserter. Removes the auto-generated ExampleInstrumentedTest placeholder. --- .../gutenbergkit/EditorInteractionTest.kt | 81 ++++ .../example/gutenbergkit/EditorTestHelpers.kt | 364 ++++++++++++++++++ .../gutenbergkit/ExampleInstrumentedTest.kt | 24 -- 3 files changed, 445 insertions(+), 24 deletions(-) create mode 100644 android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt create mode 100644 android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt delete mode 100644 android/app/src/androidTest/java/com/example/gutenbergkit/ExampleInstrumentedTest.kt 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..f37d25fe4 --- /dev/null +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt @@ -0,0 +1,81 @@ +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.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() + + // -- 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") + + // Tap undo — redo should become enabled. + composeTestRule.onNodeWithContentDescription("Undo").performClick() + EditorTestHelpers.waitForEnabled(composeTestRule, "Redo") + + // Tap redo — undo should remain enabled. + composeTestRule.onNodeWithContentDescription("Redo").performClick() + EditorTestHelpers.waitForEnabled(composeTestRule, "Undo") + } +} 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..bdefbd550 --- /dev/null +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -0,0 +1,364 @@ +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.assertion.WebViewAssertions.webMatches +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.getText +import androidx.test.espresso.web.webdriver.DriverAtoms.webClick +import androidx.test.espresso.web.webdriver.Locator +import androidx.test.ext.junit.rules.ActivityScenarioRule +import org.hamcrest.Matchers.notNullValue + +/** + * 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 + private const val POLL_INTERVAL_MS = 500L + + // CSS selectors matching the Gutenberg DOM + private const val TITLE_SELECTOR = "[aria-label='Add title']" + private const val ADD_BLOCK_SELECTOR = "[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 = "[aria-label='Add title']" + private const val CODE_EDITOR_CONTENT_SELECTOR = + "[aria-label='Start writing with text or HTML']" + + /** + * 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: AndroidComposeTestRule, MainActivity> + ) { + // Tap the "Standalone editor" card in the main list. + rule.waitUntil(ELEMENT_TIMEOUT_MS) { + runCatching { + rule.onNodeWithText("Standalone editor").assertExists() + true + }.getOrDefault(false) + } + rule.onNodeWithText("Standalone editor").performClick() + + // Wait for and tap the "Start" button on the configuration screen. + rule.waitUntil(ELEMENT_TIMEOUT_MS) { + runCatching { + rule.onNodeWithText("Start").assertExists() + true + }.getOrDefault(false) + } + 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 via JS click. + // We use JS click because the toolbar button may not pass Espresso's + // visibility check even though it is functionally present. + clickViaJs(ADD_BLOCK_SELECTOR) + // Wait for the inserter popover to render, then find and click the block + // by its title text. The WordPress Inserter component renders block items + // as buttons — we search by role and accessible name for resilience. + val escapedName = name.replace("'", "\\'") + val js = "var btn = document.querySelector(\"[role='option'][aria-label='" + escapedName + "']\");" + + "if (btn) { btn.click(); return 'clicked'; }" + + "var items = document.querySelectorAll('.block-editor-block-types-list__item');" + + "for (var i = 0; i < items.length; i++) {" + + "if (items[i].textContent.trim() === '" + escapedName + "' || " + + "(items[i].getAttribute('aria-label') || '').indexOf('" + escapedName + "') >= 0) {" + + "items[i].click();" + + "return 'clicked';" + + "}" + + "}" + + "return 'not found: ' + items.length + ' items';" + waitForConditionViaJs(js, "clicked", ELEMENT_TIMEOUT_MS) + } + + /** + * 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: AndroidComposeTestRule, MainActivity> + ) { + rule.onNodeWithContentDescription("More options").performClick() + rule.waitUntil(ELEMENT_TIMEOUT_MS) { + runCatching { + rule.onNodeWithText("Code editor").assertExists() + true + }.getOrDefault(false) + } + rule.onNodeWithText("Code editor").performClick() + } + + /** + * Switches the editor back to Visual Editor mode via the More options menu. + */ + fun switchToVisualEditor( + rule: AndroidComposeTestRule, MainActivity> + ) { + rule.onNodeWithContentDescription("More options").performClick() + rule.waitUntil(ELEMENT_TIMEOUT_MS) { + runCatching { + rule.onNodeWithText("Visual editor").assertExists() + true + }.getOrDefault(false) + } + rule.onNodeWithText("Visual editor").performClick() + } + + // -- Content Reading (Code Editor Mode) -- + + /** + * Reads the current title from the code editor's title field. + * The editor must already be in Code Editor mode. + */ + fun readTitle(): String { + return onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.CSS_SELECTOR, CODE_EDITOR_TITLE_SELECTOR)) + .perform(getText()) + .get() + } + + /** + * Reads the current raw HTML content from the code editor's content textarea. + * The editor must already be in Code Editor mode. + */ + fun readContent(): String { + return onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.CSS_SELECTOR, CODE_EDITOR_CONTENT_SELECTOR)) + .perform(getText()) + .get() + } + + /** + * Switches to Code Editor, reads both title and content, then switches back. + * Returns a [TitleAndContent] data class. + */ + fun readTitleAndContent( + rule: AndroidComposeTestRule, MainActivity> + ): TitleAndContent { + switchToCodeEditor(rule) + // Allow the code editor to render + 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: AndroidComposeTestRule, MainActivity> + ): TitleAndContent { + val result = readTitleAndContent(rule) + if (expectedTitle != null) { + assert(result.title == expectedTitle) { + "Title mismatch: expected \"$expectedTitle\" but got \"${result.title}\"" + } + } + if (expectedContentSubstring != null) { + assert(result.content.contains(expectedContentSubstring)) { + "Expected content to contain \"$expectedContentSubstring\" but got \"${result.content}\"" + } + } + return result + } + + // -- Waiting Helpers -- + + /** + * Waits until a Compose node with the given content description becomes enabled. + */ + fun waitForEnabled( + rule: AndroidComposeTestRule, MainActivity>, + contentDescription: String, + timeoutMs: Long = ELEMENT_TIMEOUT_MS + ) { + rule.waitUntil(timeoutMs) { + runCatching { + rule.onNodeWithContentDescription(contentDescription).assertIsEnabled() + true + }.getOrDefault(false) + } + } + + /** + * Asserts a Compose node with the given content description is not enabled. + */ + fun assertDisabled( + rule: AndroidComposeTestRule, MainActivity>, + contentDescription: String + ) { + rule.onNodeWithContentDescription(contentDescription).assertIsNotEnabled() + } + + // -- Internal Helpers -- + + /** + * Clicks an element by CSS selector via JavaScript. + * + * Espresso Web's `webClick()` requires the element to pass a visibility + * check, which can fail for elements in the Gutenberg toolbar. Using + * `element.click()` in JS bypasses that restriction. + */ + private fun clickViaJs(cssSelector: String) { + val escapedSelector = cssSelector.replace("'", "\\'") + // Dispatch a full mousedown→mouseup→click sequence so React's + // synthetic event system picks it up (React listens at the root + // for bubbled native events, not just .click()). + val js = "var el = document.querySelector('" + escapedSelector + "');" + + "if (!el) { return 'element not found: " + escapedSelector + "'; }" + + "el.scrollIntoView();" + + "var opts = {bubbles: true, cancelable: true, view: window};" + + "el.dispatchEvent(new PointerEvent('pointerdown', opts));" + + "el.dispatchEvent(new MouseEvent('mousedown', opts));" + + "el.dispatchEvent(new PointerEvent('pointerup', opts));" + + "el.dispatchEvent(new MouseEvent('mouseup', opts));" + + "el.dispatchEvent(new MouseEvent('click', opts));" + + "return 'clicked';" + val result = onWebView() + .forceJavascriptEnabled() + .perform(script(js)) + .get() + val value = result.value?.toString() ?: "null" + if (value.contains("not found")) { + throw AssertionError("clickViaJs failed: $value") + } + } + + /** + * 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 = "document.execCommand('insertText', false, '" + escapedText + "');" + + "return 'ok';" + onWebView() + .forceJavascriptEnabled() + .perform(script(js)) + } + + /** + * Polls a JS script until its return value matches [expectedResult]. + */ + private fun waitForConditionViaJs(js: String, expectedResult: String, timeoutMs: Long) { + val deadline = System.currentTimeMillis() + timeoutMs + var lastResult = "" + + while (System.currentTimeMillis() < deadline) { + try { + // script() returns an Evaluation; .getValue() gets the JS return value + val evaluation = onWebView() + .forceJavascriptEnabled() + .perform(script(js)) + .get() + val value = evaluation.value + lastResult = value?.toString() ?: "null" + if (lastResult.contains(expectedResult)) return + } catch (_: Throwable) { + // Ignore and retry + } + Thread.sleep(POLL_INTERVAL_MS) + } + throw AssertionError( + "Timed out waiting for JS condition. Last result: $lastResult" + ) + } + + /** + * Polls until a WebView element matching the given CSS selector exists. + */ + private fun waitForWebViewElement(cssSelector: String, timeoutMs: Long) { + val deadline = System.currentTimeMillis() + timeoutMs + var lastError: Throwable? = null + + while (System.currentTimeMillis() < deadline) { + try { + onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.CSS_SELECTOR, cssSelector)) + .check(webMatches(getText(), notNullValue(String::class.java))) + return + } catch (e: Throwable) { + lastError = e + Thread.sleep(POLL_INTERVAL_MS) + } + } + throw AssertionError( + "Timed out waiting for WebView element: $cssSelector", + lastError + ) + } + + data class TitleAndContent(val title: String, val content: String) +} diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/ExampleInstrumentedTest.kt deleted file mode 100644 index ad06cdccb..000000000 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.gutenbergkit - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.gutenbergkit", appContext.packageName) - } -} \ No newline at end of file From 567c7c7a01edf9910675238cb34ec83e0581ac22 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 11:30:01 -0500 Subject: [PATCH 03/24] test: Add Makefile targets for Android E2E tests Add test-android-e2e (production build) and test-android-e2e-dev (Vite dev server) targets, mirroring the existing test-ios-e2e / test-ios-e2e-dev pattern. Includes an ENSURE_ANDROID_DEVICE macro that checks for any connected device via adb and auto-boots the first available AVD if none is found. --- Makefile | 49 +++++++++++++++++ .../gutenbergkit/EditorInteractionTest.kt | 20 +++++++ .../example/gutenbergkit/EditorTestHelpers.kt | 55 +++++++++++++------ 3 files changed, 108 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 0f6b89a76..d3ba65cad 100644 --- a/Makefile +++ b/Makefile @@ -257,6 +257,55 @@ 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 & \ + echo "--- :hourglass: Waiting for emulator to boot..."; \ + adb wait-for-device; \ + while [ "$$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do \ + 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 + @if [ ! -d "./android/Gutenberg/src/main/assets" ]; then \ + echo "--- :open_file_folder: Copying build into Android bundle"; \ + cp -r ./dist/. ./android/Gutenberg/src/main/assets; \ + fi + $(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/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt index f37d25fe4..6a59e88a0 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt @@ -70,12 +70,32 @@ class EditorInteractionTest { // 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) + assert(!afterUndo.content.contains("World")) { + "Content should not contain undone text but got \"${afterUndo.content}\"" + } + // 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 index bdefbd550..1874e1e4e 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -35,9 +35,10 @@ object EditorTestHelpers { private const val ADD_BLOCK_SELECTOR = "[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 = "[aria-label='Add title']" + private const val CODE_EDITOR_TITLE_SELECTOR = + "textarea[placeholder='Add title']" private const val CODE_EDITOR_CONTENT_SELECTOR = - "[aria-label='Start writing with text or HTML']" + "textarea.editor-post-text-editor" /** * Navigates from the main list through the configuration screen @@ -166,27 +167,19 @@ object EditorTestHelpers { // -- Content Reading (Code Editor Mode) -- /** - * Reads the current title from the code editor's title field. + * 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 onWebView() - .forceJavascriptEnabled() - .withElement(findElement(Locator.CSS_SELECTOR, CODE_EDITOR_TITLE_SELECTOR)) - .perform(getText()) - .get() + return readTextViaJs(CODE_EDITOR_TITLE_SELECTOR) } /** - * Reads the current raw HTML content from the code editor's content textarea. + * 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 onWebView() - .forceJavascriptEnabled() - .withElement(findElement(Locator.CSS_SELECTOR, CODE_EDITOR_CONTENT_SELECTOR)) - .perform(getText()) - .get() + return readTextViaJs(CODE_EDITOR_CONTENT_SELECTOR) } /** @@ -197,8 +190,8 @@ object EditorTestHelpers { rule: AndroidComposeTestRule, MainActivity> ): TitleAndContent { switchToCodeEditor(rule) - // Allow the code editor to render - waitForWebViewElement(CODE_EDITOR_CONTENT_SELECTOR, ELEMENT_TIMEOUT_MS) + // Wait for the code editor content textarea to appear in the DOM. + waitForWebViewElementViaJs(CODE_EDITOR_CONTENT_SELECTOR, ELEMENT_TIMEOUT_MS) val title = readTitle() val content = readContent() switchToVisualEditor(rule) @@ -308,6 +301,36 @@ object EditorTestHelpers { .perform(script(js)) } + /** + * Reads the text content of an element by CSS selector via JS. + * Uses `textContent` for contenteditable elements and `value` for + * textarea/input elements, matching how Code Editor fields render. + */ + private fun readTextViaJs(cssSelector: String): String { + val escapedSelector = cssSelector.replace("'", "\\'") + val js = "var el = document.querySelector('" + escapedSelector + "');" + + "if (!el) return '';" + + "if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.value || '';" + + "return el.innerText || el.textContent || '';" + val result = onWebView() + .forceJavascriptEnabled() + .perform(script(js)) + .get() + return result.value?.toString() ?: "" + } + + /** + * Polls until a WebView element matching the CSS selector exists via JS. + * Unlike [waitForWebViewElement], this avoids Espresso Web's findElement + * which can fail on elements that don't pass visibility checks. + */ + private fun waitForWebViewElementViaJs(cssSelector: String, timeoutMs: Long) { + val escapedSelector = cssSelector.replace("'", "\\'") + val js = "var el = document.querySelector('" + escapedSelector + "');" + + "return el ? 'found' : 'not found';" + waitForConditionViaJs(js, "found", timeoutMs) + } + /** * Polls a JS script until its return value matches [expectedResult]. */ From ad9d5a771b93e0849255eaab6561d722b912885a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 13:24:44 -0500 Subject: [PATCH 04/24] fix: Always replace Android assets before E2E tests The Android assets directory is checked into git, so the conditional copy (only when the directory doesn't exist) would always skip, leaving stale build output. Always clean and replace with the current dist/ contents, matching the fix applied to iOS in PR #338. --- Makefile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index d3ba65cad..3124503c3 100644 --- a/Makefile +++ b/Makefile @@ -287,10 +287,9 @@ test-android-e2e: ## Run Android E2E tests against the production build else \ echo "--- :white_check_mark: Using existing build. Use 'make build REFRESH_JS_BUILD=1' to rebuild."; \ fi - @if [ ! -d "./android/Gutenberg/src/main/assets" ]; then \ - echo "--- :open_file_folder: Copying build into Android bundle"; \ - cp -r ./dist/. ./android/Gutenberg/src/main/assets; \ - 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 From 38a02287d960ea40cd1283ed6a0749ec31082258 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 15:17:11 -0500 Subject: [PATCH 05/24] fix: Restore emulator animations after E2E tests Replace `testOptions { animationsDisabled = true }` with a custom DisableAnimationsRule that saves the original animation scale values, disables them for the test, and restores them in a finally block. The Gradle setting permanently disabled animations on the emulator with no cleanup. The rule-based approach ensures animations are always restored, even if tests fail. --- android/app/build.gradle.kts | 3 - .../gutenbergkit/DisableAnimationsRule.kt | 64 +++++++++++++++++++ .../gutenbergkit/EditorInteractionTest.kt | 3 + 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8e1bf9d3a..dd85f72bf 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,9 +54,6 @@ android { kotlinOptions { jvmTarget = "1.8" } - testOptions { - animationsDisabled = true - } buildFeatures { compose = true buildConfig = true 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..fbb55bc63 --- /dev/null +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt @@ -0,0 +1,64 @@ +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 -> + setting to executeShellCommand(uiAutomation, "settings get global $setting") + } + + 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 index 6a59e88a0..4c6340475 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt @@ -28,6 +28,9 @@ class EditorInteractionTest { @get:Rule val composeTestRule = createAndroidComposeRule() + @get:Rule + val disableAnimationsRule = DisableAnimationsRule() + // -- Editor Loading -- /** From 5292eaab71e2c2bcba0e603b2715f2ca4f1cff09 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 15:42:39 -0500 Subject: [PATCH 06/24] fix: Replace class-based selectors with aria attributes in Android E2E tests Use semantic selectors (aria-label, aria-modal, role, placeholder) instead of CSS class names so tests are resilient to styling refactors. - Scope ADD_BLOCK_SELECTOR to [aria-label='Editor toolbar'] to disambiguate the toolbar inserter from the inline block appender - Replace .block-editor-inserter__popover with [role='dialog'][aria-modal] and match block options by textContent within [role='option'] elements - Replace textarea.editor-post-text-editor with placeholder attribute --- .../example/gutenbergkit/EditorTestHelpers.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 1874e1e4e..596e7eaf7 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -30,15 +30,19 @@ object EditorTestHelpers { private const val ELEMENT_TIMEOUT_MS = 10_000L private const val POLL_INTERVAL_MS = 500L - // CSS selectors matching the Gutenberg DOM + // 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']" - private const val ADD_BLOCK_SELECTOR = "[aria-label='Add block']" + // 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.editor-post-text-editor" + "textarea[placeholder='Start writing with text or HTML']" /** * Navigates from the main list through the configuration screen @@ -97,21 +101,21 @@ object EditorTestHelpers { // We use JS click because the toolbar button may not pass Espresso's // visibility check even though it is functionally present. clickViaJs(ADD_BLOCK_SELECTOR) - // Wait for the inserter popover to render, then find and click the block - // by its title text. The WordPress Inserter component renders block items - // as buttons — we search by role and accessible name for resilience. + // Wait for the inserter dialog to render, then find and click the block + // by matching its text content within role="option" elements. Block items + // don't have an explicit aria-label — their accessible name comes from + // inner text content. We scope the search to the inserter dialog + // (role="dialog") to avoid matching elements in other parts of the DOM. val escapedName = name.replace("'", "\\'") - val js = "var btn = document.querySelector(\"[role='option'][aria-label='" + escapedName + "']\");" + - "if (btn) { btn.click(); return 'clicked'; }" + - "var items = document.querySelectorAll('.block-editor-block-types-list__item');" + - "for (var i = 0; i < items.length; i++) {" + - "if (items[i].textContent.trim() === '" + escapedName + "' || " + - "(items[i].getAttribute('aria-label') || '').indexOf('" + escapedName + "') >= 0) {" + - "items[i].click();" + - "return 'clicked';" + + val js = "var dialog = document.querySelector(\"[role='dialog'][aria-modal='true']\");" + + "if (!dialog) { return 'dialog not found'; }" + + "var options = dialog.querySelectorAll(\"[role='option']\");" + + "for (var i = 0; i < options.length; i++) {" + + " if (options[i].textContent.trim() === '" + escapedName + "') {" + + " options[i].click(); return 'clicked';" + + " }" + "}" + - "}" + - "return 'not found: ' + items.length + ' items';" + "return 'not found: ' + options.length + ' options checked';" waitForConditionViaJs(js, "clicked", ELEMENT_TIMEOUT_MS) } From 47b2370409abd12e3c6f92c93b944244dd1b6b94 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 15:59:06 -0500 Subject: [PATCH 07/24] fix: Replace Kotlin assert() with JUnit Assert methods in Android E2E tests Kotlin's assert() compiles to Java assert statements, which can be silently disabled at the JVM level. On Android, assertions are disabled by default unless explicitly enabled with -ea. Using JUnit's Assert.assertEquals, Assert.assertTrue, and Assert.assertFalse ensures assertions always execute during test runs. --- .../example/gutenbergkit/EditorInteractionTest.kt | 8 +++++--- .../com/example/gutenbergkit/EditorTestHelpers.kt | 13 +++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt index 4c6340475..7c6491648 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt @@ -4,6 +4,7 @@ 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 @@ -86,9 +87,10 @@ class EditorInteractionTest { // Verify the last typed text was undone. val afterUndo = EditorTestHelpers.readTitleAndContent(composeTestRule) - assert(!afterUndo.content.contains("World")) { - "Content should not contain undone text but got \"${afterUndo.content}\"" - } + 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() diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 596e7eaf7..e1c32d301 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -15,6 +15,8 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.webClick import androidx.test.espresso.web.webdriver.Locator import androidx.test.ext.junit.rules.ActivityScenarioRule import org.hamcrest.Matchers.notNullValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue /** * Reusable helpers for Android E2E tests that interact with the Gutenberg editor. @@ -213,14 +215,13 @@ object EditorTestHelpers { ): TitleAndContent { val result = readTitleAndContent(rule) if (expectedTitle != null) { - assert(result.title == expectedTitle) { - "Title mismatch: expected \"$expectedTitle\" but got \"${result.title}\"" - } + assertEquals("Title mismatch", expectedTitle, result.title) } if (expectedContentSubstring != null) { - assert(result.content.contains(expectedContentSubstring)) { - "Expected content to contain \"$expectedContentSubstring\" but got \"${result.content}\"" - } + assertTrue( + "Expected content to contain \"$expectedContentSubstring\" but got \"${result.content}\"", + result.content.contains(expectedContentSubstring) + ) } return result } From 05766c88b34dc03c0291ab0e0d7aa33814a42c3d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 15:59:30 -0500 Subject: [PATCH 08/24] fix: Quote $ANDROID_HOME paths in ENSURE_ANDROID_DEVICE macro Add quotes around $ANDROID_HOME/emulator/emulator paths so the macro handles SDK installations in directories containing spaces. --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 3124503c3..78d3e644b 100644 --- a/Makefile +++ b/Makefile @@ -263,14 +263,14 @@ 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); \ + 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 & \ + "$$ANDROID_HOME/emulator/emulator" -avd "$$AVD" -no-snapshot-load -no-audio -no-window &>/dev/null & \ echo "--- :hourglass: Waiting for emulator to boot..."; \ adb wait-for-device; \ while [ "$$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do \ From 032da283ea91449de73b99a9dc054ce5c02fe3aa Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:01:07 -0500 Subject: [PATCH 09/24] refactor: Move Compose test dependencies to version catalog Move the hardcoded androidx.compose.ui:ui-test-junit4 and ui-test-manifest dependency strings in build.gradle.kts to named entries in libs.versions.toml for consistency with the other Compose library declarations. --- android/app/build.gradle.kts | 4 ++-- android/gradle/libs.versions.toml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dd85f72bf..ea87db1e5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -85,6 +85,6 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.web) androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - debugImplementation("androidx.compose.ui:ui-test-manifest") + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 9473a69da..cff0b6f52 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -48,6 +48,8 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } From f3d53b3b2346700adf29b56c373d6a1913b0068f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:02:33 -0500 Subject: [PATCH 10/24] refactor: Extract waitUntil/runCatching pattern into extension functions Add waitUntilAsserts and waitForNodeWithText extension functions on AndroidComposeTestRule to replace the repeated waitUntil { runCatching { ... }.getOrDefault(false) } pattern that appeared in five places. --- .../example/gutenbergkit/EditorTestHelpers.kt | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index e1c32d301..332912dcc 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -55,21 +55,11 @@ object EditorTestHelpers { rule: AndroidComposeTestRule, MainActivity> ) { // Tap the "Standalone editor" card in the main list. - rule.waitUntil(ELEMENT_TIMEOUT_MS) { - runCatching { - rule.onNodeWithText("Standalone editor").assertExists() - true - }.getOrDefault(false) - } + rule.waitForNodeWithText("Standalone editor") rule.onNodeWithText("Standalone editor").performClick() // Wait for and tap the "Start" button on the configuration screen. - rule.waitUntil(ELEMENT_TIMEOUT_MS) { - runCatching { - rule.onNodeWithText("Start").assertExists() - true - }.getOrDefault(false) - } + rule.waitForNodeWithText("Start") rule.onNodeWithText("Start").performClick() // Wait for the WebView to load: poll until the title element appears. @@ -145,12 +135,7 @@ object EditorTestHelpers { rule: AndroidComposeTestRule, MainActivity> ) { rule.onNodeWithContentDescription("More options").performClick() - rule.waitUntil(ELEMENT_TIMEOUT_MS) { - runCatching { - rule.onNodeWithText("Code editor").assertExists() - true - }.getOrDefault(false) - } + rule.waitForNodeWithText("Code editor") rule.onNodeWithText("Code editor").performClick() } @@ -161,12 +146,7 @@ object EditorTestHelpers { rule: AndroidComposeTestRule, MainActivity> ) { rule.onNodeWithContentDescription("More options").performClick() - rule.waitUntil(ELEMENT_TIMEOUT_MS) { - runCatching { - rule.onNodeWithText("Visual editor").assertExists() - true - }.getOrDefault(false) - } + rule.waitForNodeWithText("Visual editor") rule.onNodeWithText("Visual editor").performClick() } @@ -236,11 +216,8 @@ object EditorTestHelpers { contentDescription: String, timeoutMs: Long = ELEMENT_TIMEOUT_MS ) { - rule.waitUntil(timeoutMs) { - runCatching { - rule.onNodeWithContentDescription(contentDescription).assertIsEnabled() - true - }.getOrDefault(false) + rule.waitUntilAsserts(timeoutMs) { + onNodeWithContentDescription(contentDescription).assertIsEnabled() } } @@ -390,3 +367,28 @@ object EditorTestHelpers { data class TitleAndContent(val title: String, val content: String) } + +/** + * Polls until [block] completes without throwing, or times out. + * Useful for waiting on Compose assertions that throw when unsatisfied. + */ +private fun AndroidComposeTestRule<*, *>.waitUntilAsserts( + timeoutMs: Long = 10_000L, + block: AndroidComposeTestRule<*, *>.() -> Unit +) { + waitUntil(timeoutMs) { + runCatching { block(); true }.getOrDefault(false) + } +} + +/** + * Waits until a Compose node with the given [text] exists. + */ +private fun AndroidComposeTestRule<*, *>.waitForNodeWithText( + text: String, + timeoutMs: Long = 10_000L +) { + waitUntilAsserts(timeoutMs) { + onNodeWithText(text).assertExists() + } +} From 489fb333aac0b5fda66128b3753a7d494f2a5c46 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:03:25 -0500 Subject: [PATCH 11/24] refactor: Simplify clickViaJs to use el.click() instead of full event sequence The previous implementation dispatched five synthetic events (pointerdown, mousedown, pointerup, mouseup, click) to ensure React picked up the interaction. In practice, el.click() dispatches a real click event that bubbles through the DOM, which is sufficient for React 18's event delegation. This simplifies the JS and removes the unnecessary PointerEvent/MouseEvent ceremony. --- .../java/com/example/gutenbergkit/EditorTestHelpers.kt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 332912dcc..e672761ba 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -242,18 +242,10 @@ object EditorTestHelpers { */ private fun clickViaJs(cssSelector: String) { val escapedSelector = cssSelector.replace("'", "\\'") - // Dispatch a full mousedown→mouseup→click sequence so React's - // synthetic event system picks it up (React listens at the root - // for bubbled native events, not just .click()). val js = "var el = document.querySelector('" + escapedSelector + "');" + "if (!el) { return 'element not found: " + escapedSelector + "'; }" + "el.scrollIntoView();" + - "var opts = {bubbles: true, cancelable: true, view: window};" + - "el.dispatchEvent(new PointerEvent('pointerdown', opts));" + - "el.dispatchEvent(new MouseEvent('mousedown', opts));" + - "el.dispatchEvent(new PointerEvent('pointerup', opts));" + - "el.dispatchEvent(new MouseEvent('mouseup', opts));" + - "el.dispatchEvent(new MouseEvent('click', opts));" + + "el.click();" + "return 'clicked';" val result = onWebView() .forceJavascriptEnabled() From af9ffd0dbd1ee4047810f766559f0395fe249515 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:05:23 -0500 Subject: [PATCH 12/24] refactor: Consolidate WebView element waiting into single JS-based approach Replace the dual waiting mechanism (Espresso Web findElement + JS querySelector) with a single JS-based approach. The Espresso Web variant used findElement with a getText/webMatches assertion, which can fail on elements that don't pass Espresso's built-in visibility check. The JS approach using document.querySelector is simpler and handles both visible and non-visible elements consistently. This removes the webMatches, getText, and notNullValue imports that were only needed by the Espresso-based variant. --- .../example/gutenbergkit/EditorTestHelpers.kt | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index e672761ba..194be32ed 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -6,15 +6,12 @@ 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.assertion.WebViewAssertions.webMatches 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.getText import androidx.test.espresso.web.webdriver.DriverAtoms.webClick import androidx.test.espresso.web.webdriver.Locator import androidx.test.ext.junit.rules.ActivityScenarioRule -import org.hamcrest.Matchers.notNullValue import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -177,7 +174,7 @@ object EditorTestHelpers { ): TitleAndContent { switchToCodeEditor(rule) // Wait for the code editor content textarea to appear in the DOM. - waitForWebViewElementViaJs(CODE_EDITOR_CONTENT_SELECTOR, ELEMENT_TIMEOUT_MS) + waitForWebViewElement(CODE_EDITOR_CONTENT_SELECTOR, ELEMENT_TIMEOUT_MS) val title = readTitle() val content = readContent() switchToVisualEditor(rule) @@ -294,11 +291,12 @@ object EditorTestHelpers { } /** - * Polls until a WebView element matching the CSS selector exists via JS. - * Unlike [waitForWebViewElement], this avoids Espresso Web's findElement - * which can fail on elements that don't pass visibility checks. + * Polls until a WebView element matching the CSS selector exists. + * Uses `document.querySelector` in JS rather than Espresso Web's + * `findElement`, which can fail on elements that don't pass its + * built-in visibility check. */ - private fun waitForWebViewElementViaJs(cssSelector: String, timeoutMs: Long) { + private fun waitForWebViewElement(cssSelector: String, timeoutMs: Long) { val escapedSelector = cssSelector.replace("'", "\\'") val js = "var el = document.querySelector('" + escapedSelector + "');" + "return el ? 'found' : 'not found';" @@ -314,7 +312,6 @@ object EditorTestHelpers { while (System.currentTimeMillis() < deadline) { try { - // script() returns an Evaluation; .getValue() gets the JS return value val evaluation = onWebView() .forceJavascriptEnabled() .perform(script(js)) @@ -332,31 +329,6 @@ object EditorTestHelpers { ) } - /** - * Polls until a WebView element matching the given CSS selector exists. - */ - private fun waitForWebViewElement(cssSelector: String, timeoutMs: Long) { - val deadline = System.currentTimeMillis() + timeoutMs - var lastError: Throwable? = null - - while (System.currentTimeMillis() < deadline) { - try { - onWebView() - .forceJavascriptEnabled() - .withElement(findElement(Locator.CSS_SELECTOR, cssSelector)) - .check(webMatches(getText(), notNullValue(String::class.java))) - return - } catch (e: Throwable) { - lastError = e - Thread.sleep(POLL_INTERVAL_MS) - } - } - throw AssertionError( - "Timed out waiting for WebView element: $cssSelector", - lastError - ) - } - data class TitleAndContent(val title: String, val content: String) } From 163a35b865000032a019283bad1c71ad262e2207 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:06:38 -0500 Subject: [PATCH 13/24] refactor: Use Espresso Web XPath locator for block insertion Replace the hand-rolled JS that iterates over role="option" elements to find and click a block by name with Espresso Web's findElement using an XPath locator. This is more idiomatic and leverages the framework's built-in element location instead of custom JS string matching. The dialog wait still uses the JS-based waitForWebViewElement to ensure the inserter is rendered before attempting the XPath lookup. --- .../example/gutenbergkit/EditorTestHelpers.kt | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 194be32ed..d9f7ae4b4 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -90,22 +90,15 @@ object EditorTestHelpers { // We use JS click because the toolbar button may not pass Espresso's // visibility check even though it is functionally present. clickViaJs(ADD_BLOCK_SELECTOR) - // Wait for the inserter dialog to render, then find and click the block - // by matching its text content within role="option" elements. Block items - // don't have an explicit aria-label — their accessible name comes from - // inner text content. We scope the search to the inserter dialog - // (role="dialog") to avoid matching elements in other parts of the DOM. - val escapedName = name.replace("'", "\\'") - val js = "var dialog = document.querySelector(\"[role='dialog'][aria-modal='true']\");" + - "if (!dialog) { return 'dialog not found'; }" + - "var options = dialog.querySelectorAll(\"[role='option']\");" + - "for (var i = 0; i < options.length; i++) {" + - " if (options[i].textContent.trim() === '" + escapedName + "') {" + - " options[i].click(); return 'clicked';" + - " }" + - "}" + - "return 'not found: ' + options.length + ' options checked';" - waitForConditionViaJs(js, "clicked", ELEMENT_TIMEOUT_MS) + // 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("[role='dialog'][aria-modal='true']", ELEMENT_TIMEOUT_MS) + val xpath = "//*[@role='dialog'][@aria-modal='true']//*[@role='option'][normalize-space()='$name']" + onWebView() + .forceJavascriptEnabled() + .withElement(findElement(Locator.XPATH, xpath)) + .perform(webClick()) } /** From 92d00aeb7ce240264b375b79225dcb0969326eda Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:09:29 -0500 Subject: [PATCH 14/24] refactor: Extract inline inserter dialog selectors into named values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insertBlock method had two inline selectors — a CSS selector for the inserter dialog and an XPath for matching block options by name. Extract the CSS selector to INSERTER_DIALOG_SELECTOR (alongside the other named constants) and the XPath template to an inserterOptionXpath helper function for clarity. --- .../com/example/gutenbergkit/EditorTestHelpers.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index d9f7ae4b4..3031900c4 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -42,6 +42,8 @@ object EditorTestHelpers { "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 @@ -93,11 +95,10 @@ object EditorTestHelpers { // 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("[role='dialog'][aria-modal='true']", ELEMENT_TIMEOUT_MS) - val xpath = "//*[@role='dialog'][@aria-modal='true']//*[@role='option'][normalize-space()='$name']" + waitForWebViewElement(INSERTER_DIALOG_SELECTOR, ELEMENT_TIMEOUT_MS) onWebView() .forceJavascriptEnabled() - .withElement(findElement(Locator.XPATH, xpath)) + .withElement(findElement(Locator.XPATH, inserterOptionXpath(name))) .perform(webClick()) } @@ -223,6 +224,13 @@ object EditorTestHelpers { // -- Internal Helpers -- + /** + * 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']" + /** * Clicks an element by CSS selector via JavaScript. * From 80230a9f467aaceb4ed8831ed044d2d919a1264e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:19:10 -0500 Subject: [PATCH 15/24] refactor: Extract runJs() helper to deduplicate WebView JS execution Centralizes the repeated `onWebView().forceJavascriptEnabled() .perform(script(js)).get()` boilerplate into a single `runJs()` method, used by clickViaJs, typeViaExecCommand, readTextViaJs, and waitForConditionViaJs. --- .../example/gutenbergkit/EditorTestHelpers.kt | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 3031900c4..9c7b71ac7 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -224,6 +224,18 @@ object EditorTestHelpers { // -- 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"). @@ -245,11 +257,7 @@ object EditorTestHelpers { "el.scrollIntoView();" + "el.click();" + "return 'clicked';" - val result = onWebView() - .forceJavascriptEnabled() - .perform(script(js)) - .get() - val value = result.value?.toString() ?: "null" + val value = runJs(js) if (value.contains("not found")) { throw AssertionError("clickViaJs failed: $value") } @@ -268,9 +276,7 @@ object EditorTestHelpers { val escapedText = text.replace("\\", "\\\\").replace("'", "\\'") val js = "document.execCommand('insertText', false, '" + escapedText + "');" + "return 'ok';" - onWebView() - .forceJavascriptEnabled() - .perform(script(js)) + runJs(js) } /** @@ -284,11 +290,7 @@ object EditorTestHelpers { "if (!el) return '';" + "if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.value || '';" + "return el.innerText || el.textContent || '';" - val result = onWebView() - .forceJavascriptEnabled() - .perform(script(js)) - .get() - return result.value?.toString() ?: "" + return runJs(js) } /** @@ -313,12 +315,7 @@ object EditorTestHelpers { while (System.currentTimeMillis() < deadline) { try { - val evaluation = onWebView() - .forceJavascriptEnabled() - .perform(script(js)) - .get() - val value = evaluation.value - lastResult = value?.toString() ?: "null" + lastResult = runJs(js) if (lastResult.contains(expectedResult)) return } catch (_: Throwable) { // Ignore and retry From 28b4574bf47404a737894438b8069f91da311bbc Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:19:27 -0500 Subject: [PATCH 16/24] fix: Narrow catch (Throwable) to catch (Exception) in waitForConditionViaJs Avoids silently swallowing JVM errors like OutOfMemoryError or StackOverflowError during WebView polling. --- .../java/com/example/gutenbergkit/EditorTestHelpers.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 9c7b71ac7..546328530 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -317,7 +317,7 @@ object EditorTestHelpers { try { lastResult = runJs(js) if (lastResult.contains(expectedResult)) return - } catch (_: Throwable) { + } catch (_: Exception) { // Ignore and retry } Thread.sleep(POLL_INTERVAL_MS) From 2207813f26ff5e5b8f238921f0f41c199162cc78 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:20:12 -0500 Subject: [PATCH 17/24] refactor: Use Kotlin multiline strings for JS snippets Replace string concatenation with trimIndent() multiline strings in clickViaJs, typeViaExecCommand, readTextViaJs, and waitForWebViewElement for improved readability. --- .../example/gutenbergkit/EditorTestHelpers.kt | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 546328530..b6114b2bb 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -252,11 +252,13 @@ object EditorTestHelpers { */ private fun clickViaJs(cssSelector: String) { val escapedSelector = cssSelector.replace("'", "\\'") - val js = "var el = document.querySelector('" + escapedSelector + "');" + - "if (!el) { return 'element not found: " + escapedSelector + "'; }" + - "el.scrollIntoView();" + - "el.click();" + - "return 'clicked';" + val js = """ + var el = document.querySelector('$escapedSelector'); + if (!el) return 'element not found: $escapedSelector'; + el.scrollIntoView(); + el.click(); + return 'clicked'; + """.trimIndent() val value = runJs(js) if (value.contains("not found")) { throw AssertionError("clickViaJs failed: $value") @@ -274,8 +276,10 @@ object EditorTestHelpers { */ private fun typeViaExecCommand(text: String) { val escapedText = text.replace("\\", "\\\\").replace("'", "\\'") - val js = "document.execCommand('insertText', false, '" + escapedText + "');" + - "return 'ok';" + val js = """ + document.execCommand('insertText', false, '$escapedText'); + return 'ok'; + """.trimIndent() runJs(js) } @@ -286,10 +290,12 @@ object EditorTestHelpers { */ private fun readTextViaJs(cssSelector: String): String { val escapedSelector = cssSelector.replace("'", "\\'") - val js = "var el = document.querySelector('" + escapedSelector + "');" + - "if (!el) return '';" + - "if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.value || '';" + - "return el.innerText || el.textContent || '';" + val js = """ + var el = document.querySelector('$escapedSelector'); + if (!el) return ''; + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.value || ''; + return el.innerText || el.textContent || ''; + """.trimIndent() return runJs(js) } @@ -301,8 +307,10 @@ object EditorTestHelpers { */ private fun waitForWebViewElement(cssSelector: String, timeoutMs: Long) { val escapedSelector = cssSelector.replace("'", "\\'") - val js = "var el = document.querySelector('" + escapedSelector + "');" + - "return el ? 'found' : 'not found';" + val js = """ + var el = document.querySelector('$escapedSelector'); + return el ? 'found' : 'not found'; + """.trimIndent() waitForConditionViaJs(js, "found", timeoutMs) } From 2f893daddcdfe64a55a22d7671f30deb25a35775 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:20:31 -0500 Subject: [PATCH 18/24] fix: Add boot timeout to ENSURE_ANDROID_DEVICE macro Prevents the emulator boot loop from hanging indefinitely by failing after 120 seconds (60 iterations x 2s sleep). --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 78d3e644b..fccc9c48f 100644 --- a/Makefile +++ b/Makefile @@ -273,7 +273,13 @@ define ENSURE_ANDROID_DEVICE "$$ANDROID_HOME/emulator/emulator" -avd "$$AVD" -no-snapshot-load -no-audio -no-window &>/dev/null & \ 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."; \ + exit 1; \ + fi; \ sleep 2; \ done; \ echo "--- :white_check_mark: Emulator booted."; \ From dc806d095eabcad8a38a1b9df49febd20ad92bbe Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:20:48 -0500 Subject: [PATCH 19/24] fix: Default to 1.0 for missing animation settings in DisableAnimationsRule When a device has no value for an animation scale setting, adb returns the string "null". Restoring "null" as the value could leave animations in an unexpected state. Default to "1.0" instead. --- .../java/com/example/gutenbergkit/DisableAnimationsRule.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt index fbb55bc63..b8424278a 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/DisableAnimationsRule.kt @@ -32,7 +32,10 @@ class DisableAnimationsRule : TestRule { .uiAutomation val originalValues = ANIMATION_SETTINGS.map { setting -> - setting to executeShellCommand(uiAutomation, "settings get global $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 { From c93c1fe2212414d92402938ca1bdb01cc727dd2a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:21:06 -0500 Subject: [PATCH 20/24] fix: Check execCommand return value in typeViaExecCommand Verify that document.execCommand('insertText') returns true and throw an AssertionError if it fails, catching silent text insertion failures. --- .../java/com/example/gutenbergkit/EditorTestHelpers.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index b6114b2bb..64ac31304 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -277,10 +277,13 @@ object EditorTestHelpers { private fun typeViaExecCommand(text: String) { val escapedText = text.replace("\\", "\\\\").replace("'", "\\'") val js = """ - document.execCommand('insertText', false, '$escapedText'); - return 'ok'; + var result = document.execCommand('insertText', false, '$escapedText'); + return result ? 'ok' : 'execCommand failed'; """.trimIndent() - runJs(js) + val value = runJs(js) + if (value.contains("failed")) { + throw AssertionError("typeViaExecCommand failed: execCommand returned false") + } } /** From 0ecc1cb45b51bb790ed8e406a05dad22e1473bb9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Feb 2026 16:21:21 -0500 Subject: [PATCH 21/24] refactor: Simplify readTextViaJs to only handle textarea elements All callers target Code Editor textarea fields, so the contenteditable branch (innerText/textContent) is unused. Simplify to just return el.value. --- .../java/com/example/gutenbergkit/EditorTestHelpers.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt index 64ac31304..f779872ea 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -287,17 +287,15 @@ object EditorTestHelpers { } /** - * Reads the text content of an element by CSS selector via JS. - * Uses `textContent` for contenteditable elements and `value` for - * textarea/input elements, matching how Code Editor fields render. + * Reads the value of a textarea/input element by CSS selector via JS. + * Used to read Code Editor fields which render as `