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 7c6491648..d246e05fd 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorInteractionTest.kt @@ -69,7 +69,7 @@ class EditorInteractionTest { EditorTestHelpers.waitForEnabled(composeTestRule, "Undo") // Insert a Paragraph block and type in the content area. - EditorTestHelpers.typeInContent("World") + EditorTestHelpers.typeInContent("World", composeTestRule) // Undo should still be enabled after typing content. 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 index 4f1bd5668..30eea47fc 100644 --- a/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt +++ b/android/app/src/androidTest/java/com/example/gutenbergkit/EditorTestHelpers.kt @@ -1,7 +1,12 @@ package com.example.gutenbergkit +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -43,8 +48,6 @@ 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 @@ -114,35 +117,53 @@ object EditorTestHelpers { } /** - * Opens the web block inserter and inserts a block by name. + * Opens the native 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. + * Taps the "Add block" toggle in the editor toolbar, then clicks the + * block tile matching [name] in the native block picker. The toggle + * lives in the WebView, but the picker it presents is native Compose — + * the demo app enables the native inserter by default, so tapping the + * toggle dispatches over the bridge rather than opening a web popover. * Mirrors `EditorUITestHelpers.insertBlock(_:webView:app:)` on iOS. */ - fun insertBlock(name: String) { + fun insertBlock( + name: String, + rule: EditorTestRule + ) { // Tap the "Add block" toggle button in the WebView toolbar. waitForWebViewElement(ADD_BLOCK_SELECTOR, ELEMENT_TIMEOUT_MS) 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()) + // Wait for the native picker, then tap the tile by name. `BlockTile` + // is a clickable `Role.Button`, and `clickable` merges descendants, so + // the label resolves onto the tile itself in the merged tree — match + // the text there rather than on a descendant. + // + // Scope to the block grid, the only `CollectionInfo` node in the + // sheet, so the match cannot stray outside it. No current block title + // collides with a category tab — `hasText` compares exactly, so + // "Media & Text" does not match the "Text" tab — but the tabs, close + // button, and search field are all clickable and labelled, so the + // scoping is what keeps that a property of the matcher rather than a + // coincidence of the current strings. + val tile = hasClickAction() and + hasText(name) and + hasAnyAncestor(SemanticsMatcher.keyIsDefined(SemanticsProperties.CollectionInfo)) + rule.waitForNode(tile, NAVIGATE_TIMEOUT_MS) + rule.onNode(tile).performClick() } /** - * Inserts a Paragraph block via the web block inserter then types + * Inserts a Paragraph block via the native block inserter then types * text into the empty block placeholder. */ - fun typeInContent(text: String) { - insertBlock("Paragraph") + fun typeInContent( + text: String, + rule: EditorTestRule + ) { + insertBlock("Paragraph", rule) // Wait for the empty block to appear after insertion. waitForWebViewElement(EMPTY_BLOCK_SELECTOR, ELEMENT_TIMEOUT_MS) onWebView() @@ -271,13 +292,6 @@ object EditorTestHelpers { 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 @@ -355,13 +369,42 @@ private fun AndroidComposeTestRule<*, *>.waitUntilAsserts( } /** - * Waits until a Compose node with the given [text] exists. + * Waits until exactly one Compose node matches [matcher]. + * + * Waiting on an `assertExists` would swallow the ambiguous case: once two + * nodes match, waiting longer cannot reduce them to one, so the poll burns + * the full timeout and reports "condition still not satisfied" — hiding the + * real cause. Polling on the match count instead lets an ambiguous matcher + * fail immediately, with a message naming it. + */ +private fun AndroidComposeTestRule<*, *>.waitForNode( + matcher: SemanticsMatcher, + timeoutMs: Long = 10_000L +) { + // Poll on the match count rather than `assertExists`, so an ambiguous + // match fails as soon as it appears instead of burning the full timeout + // on an assertion that can never pass. + waitUntil(timeoutMs) { + onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + } + val matches = onAllNodes(matcher).fetchSemanticsNodes() + check(matches.size == 1) { + "Expected exactly 1 node matching '${matcher.description}' but found " + + "${matches.size}. Narrow the matcher — an ambiguous match cannot " + + "resolve by waiting." + } +} + +/** + * Waits until exactly one Compose node with the given [text] exists. + * + * Every caller clicks the node afterwards, which already requires a unique + * match, so this shares [waitForNode]'s ambiguity check. */ private fun AndroidComposeTestRule<*, *>.waitForNodeWithText( text: String, timeoutMs: Long = 10_000L ) { - waitUntilAsserts(timeoutMs) { - onNodeWithText(text).assertExists() - } + waitForNode(hasText(text), timeoutMs) } + diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index cc43e89d5..582840ef1 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -19,7 +19,7 @@ import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.PostType as WpPostType data class SitePreparationUiState( - val enableNativeInserter: Boolean = false, + val enableNativeInserter: Boolean = true, val enableInserterMediaStrip: Boolean = false, val enableNetworkLogging: Boolean = false, /** All viewable post types fetched from the site, or empty while loading. */ diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 886094046..d21e2d07e 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -100,6 +100,7 @@ const EditorToolbar = ( { className } ) => { const addBlockButton = enableNativeBlockInserter ? ( diff --git a/src/components/editor-toolbar/style.scss b/src/components/editor-toolbar/style.scss index 65851d24a..7514264a9 100644 --- a/src/components/editor-toolbar/style.scss +++ b/src/components/editor-toolbar/style.scss @@ -145,17 +145,6 @@ $scroll-indicator-elevation: 32; right: 6px; } -// Style the add block button with rounded black background -.gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button { +.gutenberg-kit-editor-toolbar__inserter { margin-inline-start: 8px; - - svg { - background: #eae9ec; - border-radius: 18px; - color: wordpress.$black; - padding: 1px; - width: 32px; - height: 32px; - display: block; - } } diff --git a/src/components/native-inserter/index.jsx b/src/components/native-inserter/index.jsx index bc348fcd4..5bdcad9d6 100644 --- a/src/components/native-inserter/index.jsx +++ b/src/components/native-inserter/index.jsx @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + /** * WordPress dependencies */ @@ -39,6 +44,7 @@ import useBlockTypesState from '@wordpress/block-editor/build-module/components/ /** * Internal dependencies */ +import './style.scss'; import { debug } from '../../utils/logger'; import { preprocessBlockTypesForNativeInserter, @@ -58,11 +64,16 @@ import { unlock } from '../../lock-unlock'; * * Mimics the WordPress Inserter component API with open/onToggle props. * - * @param {Object} props Component props - * @param {boolean} props.open Whether the inserter is open - * @param {Function} props.onToggle Callback to toggle inserter open state + * @param {Object} props Component props + * @param {string} props.className Optional CSS class for styling + * @param {boolean} props.open Whether the inserter is open + * @param {Function} props.onToggle Callback to toggle inserter open state */ -export default function NativeBlockInserterButton( { open, onToggle } ) { +export default function NativeBlockInserterButton( { + className, + open, + onToggle, +} ) { const buttonRef = useRef( null ); const prevOpen = useRef( false ); @@ -418,6 +429,8 @@ export default function NativeBlockInserterButton( { open, onToggle } ) { prevOpen.current = open; }, [ open, prepareAndShowInserter ] ); + const classes = clsx( 'gutenberg-kit-add-block-button', className ); + return (