From 6df7652000842e28ef82f5291993758bbd6b5f9b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:36:28 -0500 Subject: [PATCH 01/21] test: Add iOS UI test target and editor load tests (XCUITest) --- .../Gutenberg.xcodeproj/project.pbxproj | 111 ++++++++++++++++++ .../GutenbergUITests/EditorLoadUITest.swift | 63 ++++++++++ 2 files changed, 174 insertions(+) create mode 100644 ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index def52d87..ebe8102d 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -17,6 +17,16 @@ 2468526C2EAACCA100ED1F09 /* ConfigurationStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + AA0000012F00000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0C4F59832BEFF4970028BD96 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0C4F598A2BEFF4970028BD96; + remoteInfo = Gutenberg; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 0C4F598B2BEFF4970028BD96 /* Gutenberg.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gutenberg.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0C4F59A72BEFF4980028BD96 /* ConfigurationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationItem.swift; sourceTree = ""; }; @@ -26,10 +36,12 @@ 0CE8E7922C339B1B00B9DC67 /* GutenbergKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = GutenbergKit; path = ../..; sourceTree = ""; }; 246852682EAACCA100ED1F09 /* AuthenticationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationManager.swift; sourceTree = ""; }; 246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStorage.swift; sourceTree = ""; }; + AA0000012F00000000000001 /* GutenbergUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GutenbergUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 2468525B2EAAC62B00ED1F09 /* Views */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Views; sourceTree = ""; }; + AA0000012F0000000000000B /* GutenbergUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GutenbergUITests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -42,6 +54,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000012F00000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -52,6 +71,7 @@ 0CE8E7882C339B0600B9DC67 /* Sources */, 0C83424D2C339B7F00CAA762 /* Resources */, 0CE8E78A2C339B0600B9DC67 /* PreviewContent */, + AA0000012F0000000000000B /* GutenbergUITests */, 0C4F598C2BEFF4970028BD96 /* Products */, 0CF6E04A2BEFF60E00EDEE8A /* Frameworks */, ); @@ -61,6 +81,7 @@ isa = PBXGroup; children = ( 0C4F598B2BEFF4970028BD96 /* Gutenberg.app */, + AA0000012F00000000000001 /* GutenbergUITests.xctest */, ); name = Products; sourceTree = ""; @@ -136,6 +157,27 @@ productReference = 0C4F598B2BEFF4970028BD96 /* Gutenberg.app */; productType = "com.apple.product-type.application"; }; + AA0000012F00000000000002 /* GutenbergUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000012F00000000000008 /* Build configuration list for PBXNativeTarget "GutenbergUITests" */; + buildPhases = ( + AA0000012F00000000000003 /* Sources */, + AA0000012F00000000000004 /* Frameworks */, + AA0000012F00000000000005 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AA0000012F00000000000006 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + AA0000012F0000000000000B /* GutenbergUITests */, + ); + name = GutenbergUITests; + productName = GutenbergUITests; + productReference = AA0000012F00000000000001 /* GutenbergUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -149,6 +191,10 @@ 0C4F598A2BEFF4970028BD96 = { CreatedOnToolsVersion = 15.1; }; + AA0000012F00000000000002 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = 0C4F598A2BEFF4970028BD96; + }; }; }; buildConfigurationList = 0C4F59862BEFF4970028BD96 /* Build configuration list for PBXProject "Gutenberg" */; @@ -168,6 +214,7 @@ projectRoot = ""; targets = ( 0C4F598A2BEFF4970028BD96 /* Gutenberg */, + AA0000012F00000000000002 /* GutenbergUITests */, ); }; /* End PBXProject section */ @@ -182,6 +229,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000012F00000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -196,6 +250,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000012F00000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -203,6 +264,11 @@ isa = PBXTargetDependency; productRef = 245D6BE32EDFCD640076D741 /* GutenbergKit */; }; + AA0000012F00000000000006 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0C4F598A2BEFF4970028BD96 /* Gutenberg */; + targetProxy = AA0000012F00000000000007 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -395,6 +461,42 @@ }; name = Release; }; + AA0000012F00000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.gutenberg.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Gutenberg; + }; + name = Debug; + }; + AA0000012F0000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.gutenberg.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Gutenberg; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -416,6 +518,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AA0000012F00000000000008 /* Build configuration list for PBXNativeTarget "GutenbergUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000012F00000000000009 /* Debug */, + AA0000012F0000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift new file mode 100644 index 00000000..7f58443d --- /dev/null +++ b/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift @@ -0,0 +1,63 @@ +import XCTest + +/// E2E tests verifying that the Gutenberg editor loads correctly +/// inside the native iOS Demo app. +/// +/// These tests launch the full Gutenberg Demo app via XCUIApplication +/// and interact with it through the accessibility layer. The WebView +/// content is accessed via accessibility labels exposed by WKWebView. +final class EditorLoadUITest: XCTestCase { + + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Editor Loading + + /// The app launches and displays the editor list (or an editor). + func testAppLaunches() { + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + } + + /// A WebView becomes visible after the editor finishes loading. + func testEditorWebViewBecomesVisible() throws { + // Navigate to an editor if the app starts on a list screen. + // The bundled offline config creates a local editor that loads from + // the asset bundle without network access. + let webView = app.webViews.firstMatch + let exists = webView.waitForExistence(timeout: 30) + XCTAssertTrue(exists, "Expected a WKWebView to appear after editor loads") + } + + /// The editor toolbar is rendered inside the WebView. + func testEditorToolbarExists() throws { + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30)) + + // The toolbar is a native-layer element above the WebView in the + // navigation bar. Check for standard toolbar buttons. + let undoButton = app.buttons["arrow.uturn.backward"] + let redoButton = app.buttons["arrow.uturn.forward"] + + // At least one of the undo/redo buttons should exist in the toolbar. + let toolbarExists = undoButton.exists || redoButton.exists + XCTAssertTrue(toolbarExists, "Expected undo/redo toolbar buttons to be present") + } + + /// The close/dismiss button is present in the navigation bar. + func testCloseButtonExists() throws { + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30)) + + let closeButton = app.buttons["xmark"] + XCTAssertTrue(closeButton.exists, "Expected close (xmark) button in the toolbar") + } +} From adac649097f69ccfdef8e7c2ec6131813793d290 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:36:31 -0500 Subject: [PATCH 02/21] test: Add iOS UI editor interaction and bridge tests --- .../EditorInteractionUITest.swift | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift new file mode 100644 index 00000000..a116b7c4 --- /dev/null +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -0,0 +1,118 @@ +import XCTest + +/// E2E tests for editor interactions via the native iOS 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` → `EditorViewController` → +/// `WKUserScript` injection → Gutenberg JS initialization. +final class EditorInteractionUITest: XCTestCase { + + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - WebView Content Interaction + + /// Tapping inside the WebView gives it keyboard focus. + func testWebViewAcceptsKeyboardFocus() throws { + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30)) + + // Tap in the center of the WebView to focus it. + webView.tap() + + // After tapping the editor area, a keyboard should appear. + // On simulators the software keyboard may be hidden; check + // that the WebView at least accepted the tap without crashing. + XCTAssertTrue(webView.exists) + } + + /// The overflow menu (ellipsis) opens and contains expected items. + func testOverflowMenuOpens() throws { + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30)) + + // Tap the ellipsis (more options) button. + let moreButton = app.buttons["ellipsis"] + guard moreButton.waitForExistence(timeout: 5) else { + XCTFail("Overflow menu button not found") + return + } + moreButton.tap() + + // The menu should contain a "Code Editor" or "Visual Editor" option. + let codeEditorButton = app.buttons["Code Editor"] + let visualEditorButton = app.buttons["Visual Editor"] + let hasEditorToggle = codeEditorButton.waitForExistence(timeout: 5) + || visualEditorButton.exists + XCTAssertTrue(hasEditorToggle, "Expected Code Editor/Visual Editor toggle in overflow menu") + } + + /// Switching to code editor mode and back does not crash. + func testCodeEditorToggle() throws { + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30)) + + // Open overflow menu. + let moreButton = app.buttons["ellipsis"] + guard moreButton.waitForExistence(timeout: 5) else { + XCTFail("Overflow menu button not found") + return + } + moreButton.tap() + + // Switch to code editor. + let codeEditorButton = app.buttons["Code Editor"] + guard codeEditorButton.waitForExistence(timeout: 5) else { + // Already in code editor mode — switch back. + let visualButton = app.buttons["Visual Editor"] + if visualButton.exists { visualButton.tap() } + return + } + codeEditorButton.tap() + + // Verify the WebView is still present (no crash). + XCTAssertTrue(webView.waitForExistence(timeout: 10)) + + // Switch back to visual editor. + moreButton.tap() + let visualEditorButton = app.buttons["Visual Editor"] + if visualEditorButton.waitForExistence(timeout: 5) { + visualEditorButton.tap() + } + + XCTAssertTrue(webView.waitForExistence(timeout: 10)) + } + + /// Undo button state reflects editor history. + /// + /// On a fresh empty editor, undo should be disabled. After typing, + /// the native undo button should become enabled (the bridge sends + /// `onEditorHistoryChanged` with `hasUndo: true`). + func testUndoButtonReflectsEditorState() throws { + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30)) + + let undoButton = app.buttons["arrow.uturn.backward"] + guard undoButton.exists else { + XCTFail("Undo button not found") + return + } + + // On a fresh editor, undo should be disabled. + XCTAssertFalse(undoButton.isEnabled, "Undo should be disabled on a fresh editor") + } +} From 746cec4e637e90af70693e679cc57a1a51ecad00 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:36:55 -0500 Subject: [PATCH 03/21] test: Add Makefile target and docs for iOS E2E tests --- Makefile | 11 +++++++++++ docs/code/testing.md | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Makefile b/Makefile index e070fb75..59359651 100644 --- a/Makefile +++ b/Makefile @@ -189,6 +189,17 @@ test-js-watch: npm-dependencies ## Run JavaScript tests in watch mode test-swift-package: build ## Run Swift package tests $(call XCODEBUILD_CMD, test) +.PHONY: test-ios-e2e +test-ios-e2e: build ## Run iOS E2E tests (requires Xcode and a simulator) + @echo "--- :ios: Running iOS E2E Tests" + @set -o pipefail && \ + xcodebuild test \ + -project ./ios/Demo-iOS/Gutenberg.xcodeproj \ + -scheme GutenbergUITests \ + -sdk iphonesimulator \ + -destination '${SIMULATOR_DESTINATION}' \ + | xcbeautify + .PHONY: test-android test-android: ## Run Android tests @echo "--- :android: Running Android Tests" diff --git a/docs/code/testing.md b/docs/code/testing.md index e56c70e7..e64923cd 100644 --- a/docs/code/testing.md +++ b/docs/code/testing.md @@ -46,6 +46,23 @@ Run in interactive UI mode: make test-e2e-ui ``` +### iOS E2E Tests + +- Framework: XCUITest +- Test files: `ios/Demo-iOS/GutenbergUITests/` +- Requires: Xcode and an iOS Simulator + +These tests launch the Demo iOS app via `XCUIApplication` and verify native shell behavior — toolbar rendering, menu interactions, WebView lifecycle, and native-to-JS bridge state synchronization. + +To run the iOS E2E tests: + +```bash +make test-ios-e2e +``` + +> **Note:** The web editor must be built first (`make build`). The E2E +> target depends on `build` and will handle this automatically. + ## Code Quality Before submitting a pull request, ensure your code passes formatting and linting checks. From 6af8fe36b51f0acd3371a400b021e880c38587ea Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:37:00 -0500 Subject: [PATCH 04/21] fix: Add editor navigation and accessibility labels to iOS E2E tests --- .../EditorInteractionUITest.swift | 44 +++++++++++++----- .../GutenbergUITests/EditorLoadUITest.swift | 46 +++++++++++++------ ios/Demo-iOS/Sources/Views/EditorView.swift | 4 ++ 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index a116b7c4..5daf7b09 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -24,12 +24,34 @@ final class EditorInteractionUITest: XCTestCase { app = nil } + // MARK: - Helpers + + /// Navigates from the editor list through the configuration screen + /// and into the full-screen editor. Returns the WebView element once + /// the editor has loaded. + @discardableResult + private func navigateToEditor() throws -> XCUIElement { + // Tap the "Default Editor" row in the list. + let defaultEditor = app.staticTexts["Default Editor"] + XCTAssertTrue(defaultEditor.waitForExistence(timeout: 10), "Default Editor row not found") + defaultEditor.tap() + + // Tap the "Start" button on the configuration screen. + let startButton = app.buttons["Start"] + XCTAssertTrue(startButton.waitForExistence(timeout: 10), "Start button not found") + startButton.tap() + + // Wait for the WebView to appear in the full-screen editor. + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30), "Expected a WKWebView to appear after editor loads") + return webView + } + // MARK: - WebView Content Interaction /// Tapping inside the WebView gives it keyboard focus. func testWebViewAcceptsKeyboardFocus() throws { - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30)) + let webView = try navigateToEditor() // Tap in the center of the WebView to focus it. webView.tap() @@ -42,11 +64,10 @@ final class EditorInteractionUITest: XCTestCase { /// The overflow menu (ellipsis) opens and contains expected items. func testOverflowMenuOpens() throws { - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30)) + try navigateToEditor() // Tap the ellipsis (more options) button. - let moreButton = app.buttons["ellipsis"] + let moreButton = app.buttons["More"] guard moreButton.waitForExistence(timeout: 5) else { XCTFail("Overflow menu button not found") return @@ -63,11 +84,10 @@ final class EditorInteractionUITest: XCTestCase { /// Switching to code editor mode and back does not crash. func testCodeEditorToggle() throws { - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30)) + try navigateToEditor() // Open overflow menu. - let moreButton = app.buttons["ellipsis"] + let moreButton = app.buttons["More"] guard moreButton.waitForExistence(timeout: 5) else { XCTFail("Overflow menu button not found") return @@ -85,6 +105,7 @@ final class EditorInteractionUITest: XCTestCase { codeEditorButton.tap() // Verify the WebView is still present (no crash). + let webView = app.webViews.firstMatch XCTAssertTrue(webView.waitForExistence(timeout: 10)) // Switch back to visual editor. @@ -103,11 +124,10 @@ final class EditorInteractionUITest: XCTestCase { /// the native undo button should become enabled (the bridge sends /// `onEditorHistoryChanged` with `hasUndo: true`). func testUndoButtonReflectsEditorState() throws { - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30)) + try navigateToEditor() - let undoButton = app.buttons["arrow.uturn.backward"] - guard undoButton.exists else { + let undoButton = app.buttons["Undo"] + guard undoButton.waitForExistence(timeout: 5) else { XCTFail("Undo button not found") return } diff --git a/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift index 7f58443d..f2b2bfb2 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift @@ -20,32 +20,49 @@ final class EditorLoadUITest: XCTestCase { app = nil } + // MARK: - Helpers + + /// Navigates from the editor list through the configuration screen + /// and into the full-screen editor. Returns the WebView element once + /// the editor has loaded. + @discardableResult + private func navigateToEditor() throws -> XCUIElement { + // Tap the "Default Editor" row in the list. + let defaultEditor = app.staticTexts["Default Editor"] + XCTAssertTrue(defaultEditor.waitForExistence(timeout: 10), "Default Editor row not found") + defaultEditor.tap() + + // Tap the "Start" button on the configuration screen. + let startButton = app.buttons["Start"] + XCTAssertTrue(startButton.waitForExistence(timeout: 10), "Start button not found") + startButton.tap() + + // Wait for the WebView to appear in the full-screen editor. + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30), "Expected a WKWebView to appear after editor loads") + return webView + } + // MARK: - Editor Loading - /// The app launches and displays the editor list (or an editor). + /// The app launches and displays the editor list. func testAppLaunches() { XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) } /// A WebView becomes visible after the editor finishes loading. func testEditorWebViewBecomesVisible() throws { - // Navigate to an editor if the app starts on a list screen. - // The bundled offline config creates a local editor that loads from - // the asset bundle without network access. - let webView = app.webViews.firstMatch - let exists = webView.waitForExistence(timeout: 30) - XCTAssertTrue(exists, "Expected a WKWebView to appear after editor loads") + try navigateToEditor() } - /// The editor toolbar is rendered inside the WebView. + /// The editor toolbar is rendered in the navigation bar. func testEditorToolbarExists() throws { - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30)) + try navigateToEditor() // The toolbar is a native-layer element above the WebView in the // navigation bar. Check for standard toolbar buttons. - let undoButton = app.buttons["arrow.uturn.backward"] - let redoButton = app.buttons["arrow.uturn.forward"] + let undoButton = app.buttons["Undo"] + let redoButton = app.buttons["Redo"] // At least one of the undo/redo buttons should exist in the toolbar. let toolbarExists = undoButton.exists || redoButton.exists @@ -54,10 +71,9 @@ final class EditorLoadUITest: XCTestCase { /// The close/dismiss button is present in the navigation bar. func testCloseButtonExists() throws { - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30)) + try navigateToEditor() - let closeButton = app.buttons["xmark"] + let closeButton = app.buttons["Close"] XCTAssertTrue(closeButton.exists, "Expected close (xmark) button in the toolbar") } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 84d96eca..6345c8dc 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -31,6 +31,7 @@ struct EditorView: View { } label: { Image(systemName: "xmark") } + .accessibilityLabel("Close") } ToolbarItemGroup(placement: .topBarTrailing) { Group { @@ -40,6 +41,7 @@ struct EditorView: View { Image(systemName: "arrow.uturn.backward") } .disabled(!viewModel.hasUndo) + .accessibilityLabel("Undo") Button { viewModel.perform(.redo) @@ -47,6 +49,7 @@ struct EditorView: View { Image(systemName: "arrow.uturn.forward") } .disabled(!viewModel.hasRedo) + .accessibilityLabel("Redo") } .disabled(viewModel.isModalDialogOpen) } @@ -89,6 +92,7 @@ struct EditorView: View { } label: { Image(systemName: "ellipsis") } + .accessibilityLabel("More") } } From 81f446f8fecd045d5945b03681b5b4ee82f64b21 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:37:03 -0500 Subject: [PATCH 05/21] chore: Add shared Xcode scheme for GutenbergUITests --- .../xcschemes/GutenbergUITests.xcscheme | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme new file mode 100644 index 00000000..1549ff57 --- /dev/null +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + From d621a63c9210b39e0afaf803b945db463c1d6331 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:37:07 -0500 Subject: [PATCH 06/21] test: Remove superficial iOS UI tests --- .../EditorInteractionUITest.swift | 71 +------------------ .../GutenbergUITests/EditorLoadUITest.swift | 27 ------- 2 files changed, 1 insertion(+), 97 deletions(-) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index 5daf7b09..79c43ecf 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -47,76 +47,7 @@ final class EditorInteractionUITest: XCTestCase { return webView } - // MARK: - WebView Content Interaction - - /// Tapping inside the WebView gives it keyboard focus. - func testWebViewAcceptsKeyboardFocus() throws { - let webView = try navigateToEditor() - - // Tap in the center of the WebView to focus it. - webView.tap() - - // After tapping the editor area, a keyboard should appear. - // On simulators the software keyboard may be hidden; check - // that the WebView at least accepted the tap without crashing. - XCTAssertTrue(webView.exists) - } - - /// The overflow menu (ellipsis) opens and contains expected items. - func testOverflowMenuOpens() throws { - try navigateToEditor() - - // Tap the ellipsis (more options) button. - let moreButton = app.buttons["More"] - guard moreButton.waitForExistence(timeout: 5) else { - XCTFail("Overflow menu button not found") - return - } - moreButton.tap() - - // The menu should contain a "Code Editor" or "Visual Editor" option. - let codeEditorButton = app.buttons["Code Editor"] - let visualEditorButton = app.buttons["Visual Editor"] - let hasEditorToggle = codeEditorButton.waitForExistence(timeout: 5) - || visualEditorButton.exists - XCTAssertTrue(hasEditorToggle, "Expected Code Editor/Visual Editor toggle in overflow menu") - } - - /// Switching to code editor mode and back does not crash. - func testCodeEditorToggle() throws { - try navigateToEditor() - - // Open overflow menu. - let moreButton = app.buttons["More"] - guard moreButton.waitForExistence(timeout: 5) else { - XCTFail("Overflow menu button not found") - return - } - moreButton.tap() - - // Switch to code editor. - let codeEditorButton = app.buttons["Code Editor"] - guard codeEditorButton.waitForExistence(timeout: 5) else { - // Already in code editor mode — switch back. - let visualButton = app.buttons["Visual Editor"] - if visualButton.exists { visualButton.tap() } - return - } - codeEditorButton.tap() - - // Verify the WebView is still present (no crash). - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 10)) - - // Switch back to visual editor. - moreButton.tap() - let visualEditorButton = app.buttons["Visual Editor"] - if visualEditorButton.waitForExistence(timeout: 5) { - visualEditorButton.tap() - } - - XCTAssertTrue(webView.waitForExistence(timeout: 10)) - } + // MARK: - Editor History /// Undo button state reflects editor history. /// diff --git a/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift index f2b2bfb2..08bd3a50 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift @@ -45,35 +45,8 @@ final class EditorLoadUITest: XCTestCase { // MARK: - Editor Loading - /// The app launches and displays the editor list. - func testAppLaunches() { - XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) - } - /// A WebView becomes visible after the editor finishes loading. func testEditorWebViewBecomesVisible() throws { try navigateToEditor() } - - /// The editor toolbar is rendered in the navigation bar. - func testEditorToolbarExists() throws { - try navigateToEditor() - - // The toolbar is a native-layer element above the WebView in the - // navigation bar. Check for standard toolbar buttons. - let undoButton = app.buttons["Undo"] - let redoButton = app.buttons["Redo"] - - // At least one of the undo/redo buttons should exist in the toolbar. - let toolbarExists = undoButton.exists || redoButton.exists - XCTAssertTrue(toolbarExists, "Expected undo/redo toolbar buttons to be present") - } - - /// The close/dismiss button is present in the navigation bar. - func testCloseButtonExists() throws { - try navigateToEditor() - - let closeButton = app.buttons["Close"] - XCTAssertTrue(closeButton.exists, "Expected close (xmark) button in the toolbar") - } } From 2c16a4fd69f486dd8a62cc9ee78b87dfef403d0c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:37:11 -0500 Subject: [PATCH 07/21] test: Add undo/redo after typing iOS UI test --- .../EditorInteractionUITest.swift | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index 79c43ecf..46ce2d04 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -66,4 +66,53 @@ final class EditorInteractionUITest: XCTestCase { // On a fresh editor, undo should be disabled. XCTAssertFalse(undoButton.isEnabled, "Undo should be disabled on a fresh editor") } + + /// Typing in the editor enables undo; tapping undo enables redo. + /// + /// Exercises the full bridge round-trip: + /// 1. Type text → Gutenberg JS sends `onEditorHistoryChanged` with `hasUndo: true` + /// 2. Tap Undo → native calls `undo()` on EditorViewController + /// 3. Gutenberg JS sends `onEditorHistoryChanged` with `hasRedo: true` + func testUndoRedoAfterTyping() throws { + let webView = try navigateToEditor() + + let undoButton = app.buttons["Undo"] + let redoButton = app.buttons["Redo"] + guard undoButton.waitForExistence(timeout: 5), + redoButton.waitForExistence(timeout: 5) else { + XCTFail("Undo/Redo buttons not found") + return + } + + // Precondition: both buttons are disabled on a fresh editor. + XCTAssertFalse(undoButton.isEnabled, "Undo should be disabled before typing") + XCTAssertFalse(redoButton.isEnabled, "Redo should be disabled before typing") + + // Tap the title field inside the WebView to gain keyboard focus, + // then type some text. + let titleField = webView.textViews["Add title"] + XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Title field not found in WebView") + titleField.tap() + titleField.typeText("Hello") + + // After typing, the bridge should report undo history available. + let undoEnabled = undoButton.waitForEnabled(timeout: 10) + XCTAssertTrue(undoEnabled, "Undo should be enabled after typing") + + // Tap undo — the bridge should now report redo history available. + undoButton.tap() + + let redoEnabled = redoButton.waitForEnabled(timeout: 10) + XCTAssertTrue(redoEnabled, "Redo should be enabled after undoing") + } +} + +extension XCUIElement { + /// Polls until `isEnabled` becomes `true` or the timeout expires. + func waitForEnabled(timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "isEnabled == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + return result == .completed + } } From 304fa47bb01c095fb8b2b86a6f79591d3e38bc94 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:37:16 -0500 Subject: [PATCH 08/21] test: Add code editor toggle and block inserter iOS UI tests --- .../EditorInteractionUITest.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index 46ce2d04..c0665aca 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -105,6 +105,77 @@ final class EditorInteractionUITest: XCTestCase { let redoEnabled = redoButton.waitForEnabled(timeout: 10) XCTAssertTrue(redoEnabled, "Redo should be enabled after undoing") } + + // MARK: - Editor Mode + + /// Type content, switch to code editor, then switch back to visual. + /// + /// Exercises the native→JS bridge: toggling `isCodeEditorEnabled` + /// calls `editor.switchEditorMode()` in the WebView. The test + /// verifies the round-trip doesn't crash and the WebView survives + /// both transitions. + func testCodeEditorToggleWithContent() throws { + let webView = try navigateToEditor() + + // Type some content into the title so the editor has state. + let titleField = webView.textViews["Add title"] + XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Title field not found in WebView") + titleField.tap() + titleField.typeText("Test Title") + + // Open the overflow menu and switch to Code Editor. + let moreButton = app.buttons["More"] + XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "More button not found") + moreButton.tap() + + let codeEditorButton = app.buttons["Code Editor"] + XCTAssertTrue(codeEditorButton.waitForExistence(timeout: 5), "Code Editor button not found in menu") + codeEditorButton.tap() + + // WebView should still exist after switching to code editor. + XCTAssertTrue(webView.waitForExistence(timeout: 10), "WebView disappeared after switching to Code Editor") + + // Switch back to Visual Editor. + moreButton.tap() + + let visualEditorButton = app.buttons["Visual Editor"] + XCTAssertTrue(visualEditorButton.waitForExistence(timeout: 5), "Visual Editor button not found in menu") + visualEditorButton.tap() + + // WebView should still exist after switching back. + XCTAssertTrue(webView.waitForExistence(timeout: 10), "WebView disappeared after switching to Visual Editor") + } + + // MARK: - Block Inserter + + /// Open the block inserter and insert an Image block. + /// + /// Exercises the full inserter bridge flow: + /// 1. Tap "Add block" in the WebView toolbar → JS sends `showBlockInserter` to native + /// 2. Native presents `BlockInserterView` as a sheet + /// 3. Tap "Image" block → native calls `window.blockInserter.insertBlock()` in JS + /// 4. Sheet dismisses and block appears in editor + func testInsertImageBlock() throws { + let webView = try navigateToEditor() + + // Tap the "Add block" button in the WebView's editor toolbar. + let addBlockButton = webView.buttons["Add block"] + XCTAssertTrue(addBlockButton.waitForExistence(timeout: 10), "Add block button not found in WebView toolbar") + addBlockButton.tap() + + // The native block inserter sheet should appear with block options. + let imageBlock = app.buttons["Image"] + XCTAssertTrue(imageBlock.waitForExistence(timeout: 10), "Image block not found in block inserter") + imageBlock.tap() + + // After selection, the inserter should dismiss and an Image block + // should appear in the editor. Look for the block's placeholder. + let imageBlockInEditor = webView.buttons["Upload"] + XCTAssertTrue( + imageBlockInEditor.waitForExistence(timeout: 10), + "Image block placeholder not found in editor after insertion" + ) + } } extension XCUIElement { From 6ef4cad6e179de313609580eb647c77a330b1b75 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:37:20 -0500 Subject: [PATCH 09/21] test: Consolidate iOS UI tests into single file --- .../EditorInteractionUITest.swift | 102 +++++++++++------- .../GutenbergUITests/EditorLoadUITest.swift | 52 --------- 2 files changed, 62 insertions(+), 92 deletions(-) delete mode 100644 ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index c0665aca..bec60267 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -47,32 +47,53 @@ final class EditorInteractionUITest: XCTestCase { return webView } - // MARK: - Editor History + /// Types text into the title field and returns the field element. + @discardableResult + private func typeInTitle(_ text: String, webView: XCUIElement) -> XCUIElement { + let titleField = webView.textViews["Add title"] + XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Title field not found in WebView") + titleField.tap() + titleField.typeText(text) + return titleField + } - /// Undo button state reflects editor history. - /// - /// On a fresh empty editor, undo should be disabled. After typing, - /// the native undo button should become enabled (the bridge sends - /// `onEditorHistoryChanged` with `hasUndo: true`). - func testUndoButtonReflectsEditorState() throws { - try navigateToEditor() + /// Inserts a Paragraph block via the block inserter, then types text + /// into it. + private func typeInContent(_ text: String, webView: XCUIElement) { + // Open the block inserter from the WebView toolbar. + let addBlockButton = webView.buttons["Add block"] + XCTAssertTrue(addBlockButton.waitForExistence(timeout: 10), "Add block button not found in WebView toolbar") + addBlockButton.tap() - let undoButton = app.buttons["Undo"] - guard undoButton.waitForExistence(timeout: 5) else { - XCTFail("Undo button not found") - return - } + // Select the Paragraph block from the native inserter sheet. + let paragraphOption = app.buttons["Paragraph"] + XCTAssertTrue(paragraphOption.waitForExistence(timeout: 10), "Paragraph block not found in block inserter") + paragraphOption.tap() - // On a fresh editor, undo should be disabled. - XCTAssertFalse(undoButton.isEnabled, "Undo should be disabled on a fresh editor") + // The new paragraph block should appear as an editable text view. + let paragraphBlock = webView.textViews["Empty block; start writing or type forward slash to choose a block"] + XCTAssertTrue(paragraphBlock.waitForExistence(timeout: 10), "Paragraph block not found after insertion") + paragraphBlock.typeText(text) } - /// Typing in the editor enables undo; tapping undo enables redo. + // MARK: - Editor Loading + + /// A WebView becomes visible after the editor finishes loading. + func testEditorWebViewBecomesVisible() throws { + try navigateToEditor() + } + + // MARK: - Editor History + + /// Typing in the title and content enables undo; tapping undo enables redo. /// /// Exercises the full bridge round-trip: - /// 1. Type text → Gutenberg JS sends `onEditorHistoryChanged` with `hasUndo: true` - /// 2. Tap Undo → native calls `undo()` on EditorViewController - /// 3. Gutenberg JS sends `onEditorHistoryChanged` with `hasRedo: true` + /// 1. Verify undo/redo are disabled on a fresh editor + /// 2. Type text in title → bridge sends `onEditorHistoryChanged` with `hasUndo: true` + /// 3. Type text in content → undo remains enabled + /// 4. Tap Undo → native calls `undo()` on EditorViewController + /// 5. Gutenberg JS sends `onEditorHistoryChanged` with `hasRedo: true` + /// 6. Tap Redo → redo disables, undo re-enables func testUndoRedoAfterTyping() throws { let webView = try navigateToEditor() @@ -84,31 +105,34 @@ final class EditorInteractionUITest: XCTestCase { return } - // Precondition: both buttons are disabled on a fresh editor. - XCTAssertFalse(undoButton.isEnabled, "Undo should be disabled before typing") - XCTAssertFalse(redoButton.isEnabled, "Redo should be disabled before typing") + // On a fresh editor, both buttons should be disabled. + XCTAssertFalse(undoButton.isEnabled, "Undo should be disabled on a fresh editor") + XCTAssertFalse(redoButton.isEnabled, "Redo should be disabled on a fresh editor") - // Tap the title field inside the WebView to gain keyboard focus, - // then type some text. - let titleField = webView.textViews["Add title"] - XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Title field not found in WebView") - titleField.tap() - titleField.typeText("Hello") + // Type in the title field. + typeInTitle("Hello", webView: webView) + + // After typing in the title, undo should become enabled. + XCTAssertTrue(undoButton.waitForEnabled(timeout: 10), "Undo should be enabled after typing in title") + + // Type in the content paragraph block. + typeInContent("World", webView: webView) - // After typing, the bridge should report undo history available. - let undoEnabled = undoButton.waitForEnabled(timeout: 10) - XCTAssertTrue(undoEnabled, "Undo should be enabled after typing") + // Undo should still be enabled after typing in content. + XCTAssertTrue(undoButton.isEnabled, "Undo should remain enabled after typing in content") - // Tap undo — the bridge should now report redo history available. + // Tap undo — redo should become enabled. undoButton.tap() + XCTAssertTrue(redoButton.waitForEnabled(timeout: 10), "Redo should be enabled after undoing") - let redoEnabled = redoButton.waitForEnabled(timeout: 10) - XCTAssertTrue(redoEnabled, "Redo should be enabled after undoing") + // Tap redo — redo should become disabled and undo should remain enabled. + redoButton.tap() + XCTAssertTrue(undoButton.waitForEnabled(timeout: 10), "Undo should be enabled after redoing") } // MARK: - Editor Mode - /// Type content, switch to code editor, then switch back to visual. + /// Type content in title and body, switch to code editor, then switch back. /// /// Exercises the native→JS bridge: toggling `isCodeEditorEnabled` /// calls `editor.switchEditorMode()` in the WebView. The test @@ -117,11 +141,9 @@ final class EditorInteractionUITest: XCTestCase { func testCodeEditorToggleWithContent() throws { let webView = try navigateToEditor() - // Type some content into the title so the editor has state. - let titleField = webView.textViews["Add title"] - XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Title field not found in WebView") - titleField.tap() - titleField.typeText("Test Title") + // Type content into both the title and the paragraph block. + typeInTitle("Test Title", webView: webView) + typeInContent("Test content", webView: webView) // Open the overflow menu and switch to Code Editor. let moreButton = app.buttons["More"] diff --git a/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift deleted file mode 100644 index 08bd3a50..00000000 --- a/ios/Demo-iOS/GutenbergUITests/EditorLoadUITest.swift +++ /dev/null @@ -1,52 +0,0 @@ -import XCTest - -/// E2E tests verifying that the Gutenberg editor loads correctly -/// inside the native iOS Demo app. -/// -/// These tests launch the full Gutenberg Demo app via XCUIApplication -/// and interact with it through the accessibility layer. The WebView -/// content is accessed via accessibility labels exposed by WKWebView. -final class EditorLoadUITest: XCTestCase { - - private var app: XCUIApplication! - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - } - - override func tearDownWithError() throws { - app = nil - } - - // MARK: - Helpers - - /// Navigates from the editor list through the configuration screen - /// and into the full-screen editor. Returns the WebView element once - /// the editor has loaded. - @discardableResult - private func navigateToEditor() throws -> XCUIElement { - // Tap the "Default Editor" row in the list. - let defaultEditor = app.staticTexts["Default Editor"] - XCTAssertTrue(defaultEditor.waitForExistence(timeout: 10), "Default Editor row not found") - defaultEditor.tap() - - // Tap the "Start" button on the configuration screen. - let startButton = app.buttons["Start"] - XCTAssertTrue(startButton.waitForExistence(timeout: 10), "Start button not found") - startButton.tap() - - // Wait for the WebView to appear in the full-screen editor. - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30), "Expected a WKWebView to appear after editor loads") - return webView - } - - // MARK: - Editor Loading - - /// A WebView becomes visible after the editor finishes loading. - func testEditorWebViewBecomesVisible() throws { - try navigateToEditor() - } -} From d04b4959a3a597c43ecee5da3f870323d8b1e250 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:37:24 -0500 Subject: [PATCH 10/21] refactor: Split typeInContent into insertBlock and typeInContent helpers --- .../EditorInteractionUITest.swift | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index bec60267..72d0e737 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -57,23 +57,22 @@ final class EditorInteractionUITest: XCTestCase { return titleField } - /// Inserts a Paragraph block via the block inserter, then types text - /// into it. - private func typeInContent(_ text: String, webView: XCUIElement) { - // Open the block inserter from the WebView toolbar. + /// Opens the block inserter and inserts a block by name. + private func insertBlock(_ name: String, webView: XCUIElement) { let addBlockButton = webView.buttons["Add block"] XCTAssertTrue(addBlockButton.waitForExistence(timeout: 10), "Add block button not found in WebView toolbar") addBlockButton.tap() - // Select the Paragraph block from the native inserter sheet. - let paragraphOption = app.buttons["Paragraph"] - XCTAssertTrue(paragraphOption.waitForExistence(timeout: 10), "Paragraph block not found in block inserter") - paragraphOption.tap() + let blockOption = app.buttons[name] + XCTAssertTrue(blockOption.waitForExistence(timeout: 10), "\(name) block not found in block inserter") + blockOption.tap() + } - // The new paragraph block should appear as an editable text view. - let paragraphBlock = webView.textViews["Empty block; start writing or type forward slash to choose a block"] - XCTAssertTrue(paragraphBlock.waitForExistence(timeout: 10), "Paragraph block not found after insertion") - paragraphBlock.typeText(text) + /// Types text into the currently focused content block. + private func typeInContent(_ text: String, webView: XCUIElement) { + let block = webView.textViews["Empty block; start writing or type forward slash to choose a block"] + XCTAssertTrue(block.waitForExistence(timeout: 10), "Editable block not found in WebView") + block.typeText(text) } // MARK: - Editor Loading @@ -115,7 +114,8 @@ final class EditorInteractionUITest: XCTestCase { // After typing in the title, undo should become enabled. XCTAssertTrue(undoButton.waitForEnabled(timeout: 10), "Undo should be enabled after typing in title") - // Type in the content paragraph block. + // Insert a Paragraph block and type in it. + insertBlock("Paragraph", webView: webView) typeInContent("World", webView: webView) // Undo should still be enabled after typing in content. @@ -141,8 +141,9 @@ final class EditorInteractionUITest: XCTestCase { func testCodeEditorToggleWithContent() throws { let webView = try navigateToEditor() - // Type content into both the title and the paragraph block. + // Type content into the title, then insert a Paragraph and type in it. typeInTitle("Test Title", webView: webView) + insertBlock("Paragraph", webView: webView) typeInContent("Test content", webView: webView) // Open the overflow menu and switch to Code Editor. @@ -180,18 +181,9 @@ final class EditorInteractionUITest: XCTestCase { func testInsertImageBlock() throws { let webView = try navigateToEditor() - // Tap the "Add block" button in the WebView's editor toolbar. - let addBlockButton = webView.buttons["Add block"] - XCTAssertTrue(addBlockButton.waitForExistence(timeout: 10), "Add block button not found in WebView toolbar") - addBlockButton.tap() - - // The native block inserter sheet should appear with block options. - let imageBlock = app.buttons["Image"] - XCTAssertTrue(imageBlock.waitForExistence(timeout: 10), "Image block not found in block inserter") - imageBlock.tap() + insertBlock("Image", webView: webView) - // After selection, the inserter should dismiss and an Image block - // should appear in the editor. Look for the block's placeholder. + // After insertion, an Image block should appear in the editor. let imageBlockInEditor = webView.buttons["Upload"] XCTAssertTrue( imageBlockInEditor.waitForExistence(timeout: 10), From 26eafd069924c9c630354ac8fc66deee3a744d03 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 08:43:48 -0500 Subject: [PATCH 11/21] perf: Disable parallel simulator testing for iOS E2E tests --- .../xcshareddata/xcschemes/GutenbergUITests.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme index 1549ff57..e08440cd 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme @@ -16,7 +16,7 @@ + parallelizable = "NO"> Date: Sun, 15 Feb 2026 08:54:55 -0500 Subject: [PATCH 12/21] refactor: Extract iOS E2E test helpers into EditorUITestHelpers Move reusable helper methods (navigateToEditor, typeInTitle, insertBlock, typeInContent) and the XCUIElement.waitForEnabled extension into a dedicated EditorUITestHelpers enum so new test files can share them without duplication. --- .../EditorInteractionUITest.swift | 83 +++---------------- .../EditorUITestHelpers.swift | 67 +++++++++++++++ 2 files changed, 78 insertions(+), 72 deletions(-) create mode 100644 ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index 72d0e737..e8e64c99 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -24,62 +24,11 @@ final class EditorInteractionUITest: XCTestCase { app = nil } - // MARK: - Helpers - - /// Navigates from the editor list through the configuration screen - /// and into the full-screen editor. Returns the WebView element once - /// the editor has loaded. - @discardableResult - private func navigateToEditor() throws -> XCUIElement { - // Tap the "Default Editor" row in the list. - let defaultEditor = app.staticTexts["Default Editor"] - XCTAssertTrue(defaultEditor.waitForExistence(timeout: 10), "Default Editor row not found") - defaultEditor.tap() - - // Tap the "Start" button on the configuration screen. - let startButton = app.buttons["Start"] - XCTAssertTrue(startButton.waitForExistence(timeout: 10), "Start button not found") - startButton.tap() - - // Wait for the WebView to appear in the full-screen editor. - let webView = app.webViews.firstMatch - XCTAssertTrue(webView.waitForExistence(timeout: 30), "Expected a WKWebView to appear after editor loads") - return webView - } - - /// Types text into the title field and returns the field element. - @discardableResult - private func typeInTitle(_ text: String, webView: XCUIElement) -> XCUIElement { - let titleField = webView.textViews["Add title"] - XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Title field not found in WebView") - titleField.tap() - titleField.typeText(text) - return titleField - } - - /// Opens the block inserter and inserts a block by name. - private func insertBlock(_ name: String, webView: XCUIElement) { - let addBlockButton = webView.buttons["Add block"] - XCTAssertTrue(addBlockButton.waitForExistence(timeout: 10), "Add block button not found in WebView toolbar") - addBlockButton.tap() - - let blockOption = app.buttons[name] - XCTAssertTrue(blockOption.waitForExistence(timeout: 10), "\(name) block not found in block inserter") - blockOption.tap() - } - - /// Types text into the currently focused content block. - private func typeInContent(_ text: String, webView: XCUIElement) { - let block = webView.textViews["Empty block; start writing or type forward slash to choose a block"] - XCTAssertTrue(block.waitForExistence(timeout: 10), "Editable block not found in WebView") - block.typeText(text) - } - // MARK: - Editor Loading /// A WebView becomes visible after the editor finishes loading. func testEditorWebViewBecomesVisible() throws { - try navigateToEditor() + try EditorUITestHelpers.navigateToEditor(app: app) } // MARK: - Editor History @@ -94,7 +43,7 @@ final class EditorInteractionUITest: XCTestCase { /// 5. Gutenberg JS sends `onEditorHistoryChanged` with `hasRedo: true` /// 6. Tap Redo → redo disables, undo re-enables func testUndoRedoAfterTyping() throws { - let webView = try navigateToEditor() + let webView = try EditorUITestHelpers.navigateToEditor(app: app) let undoButton = app.buttons["Undo"] let redoButton = app.buttons["Redo"] @@ -109,14 +58,14 @@ final class EditorInteractionUITest: XCTestCase { XCTAssertFalse(redoButton.isEnabled, "Redo should be disabled on a fresh editor") // Type in the title field. - typeInTitle("Hello", webView: webView) + EditorUITestHelpers.typeInTitle("Hello", webView: webView) // After typing in the title, undo should become enabled. XCTAssertTrue(undoButton.waitForEnabled(timeout: 10), "Undo should be enabled after typing in title") // Insert a Paragraph block and type in it. - insertBlock("Paragraph", webView: webView) - typeInContent("World", webView: webView) + EditorUITestHelpers.insertBlock("Paragraph", webView: webView, app: app) + EditorUITestHelpers.typeInContent("World", webView: webView) // Undo should still be enabled after typing in content. XCTAssertTrue(undoButton.isEnabled, "Undo should remain enabled after typing in content") @@ -139,12 +88,12 @@ final class EditorInteractionUITest: XCTestCase { /// verifies the round-trip doesn't crash and the WebView survives /// both transitions. func testCodeEditorToggleWithContent() throws { - let webView = try navigateToEditor() + let webView = try EditorUITestHelpers.navigateToEditor(app: app) // Type content into the title, then insert a Paragraph and type in it. - typeInTitle("Test Title", webView: webView) - insertBlock("Paragraph", webView: webView) - typeInContent("Test content", webView: webView) + EditorUITestHelpers.typeInTitle("Test Title", webView: webView) + EditorUITestHelpers.insertBlock("Paragraph", webView: webView, app: app) + EditorUITestHelpers.typeInContent("Test content", webView: webView) // Open the overflow menu and switch to Code Editor. let moreButton = app.buttons["More"] @@ -179,9 +128,9 @@ final class EditorInteractionUITest: XCTestCase { /// 3. Tap "Image" block → native calls `window.blockInserter.insertBlock()` in JS /// 4. Sheet dismisses and block appears in editor func testInsertImageBlock() throws { - let webView = try navigateToEditor() + let webView = try EditorUITestHelpers.navigateToEditor(app: app) - insertBlock("Image", webView: webView) + EditorUITestHelpers.insertBlock("Image", webView: webView, app: app) // After insertion, an Image block should appear in the editor. let imageBlockInEditor = webView.buttons["Upload"] @@ -191,13 +140,3 @@ final class EditorInteractionUITest: XCTestCase { ) } } - -extension XCUIElement { - /// Polls until `isEnabled` becomes `true` or the timeout expires. - func waitForEnabled(timeout: TimeInterval) -> Bool { - let predicate = NSPredicate(format: "isEnabled == true") - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) - let result = XCTWaiter.wait(for: [expectation], timeout: timeout) - return result == .completed - } -} diff --git a/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift new file mode 100644 index 00000000..0ccfe3e4 --- /dev/null +++ b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift @@ -0,0 +1,67 @@ +import XCTest + +/// Reusable helpers for iOS E2E tests that interact with the Gutenberg editor. +/// +/// All methods are `static` so they can be called from any test file +/// without needing an instance — e.g. `EditorUITestHelpers.navigateToEditor(app:)`. +enum EditorUITestHelpers { + + /// Navigates from the editor list through the configuration screen + /// and into the full-screen editor. Returns the WebView element once + /// the editor has loaded. + @discardableResult + static func navigateToEditor(app: XCUIApplication) throws -> XCUIElement { + // Tap the "Default Editor" row in the list. + let defaultEditor = app.staticTexts["Default Editor"] + XCTAssertTrue(defaultEditor.waitForExistence(timeout: 10), "Default Editor row not found") + defaultEditor.tap() + + // Tap the "Start" button on the configuration screen. + let startButton = app.buttons["Start"] + XCTAssertTrue(startButton.waitForExistence(timeout: 10), "Start button not found") + startButton.tap() + + // Wait for the WebView to appear in the full-screen editor. + let webView = app.webViews.firstMatch + XCTAssertTrue(webView.waitForExistence(timeout: 30), "Expected a WKWebView to appear after editor loads") + return webView + } + + /// Types text into the title field and returns the field element. + @discardableResult + static func typeInTitle(_ text: String, webView: XCUIElement) -> XCUIElement { + let titleField = webView.textViews["Add title"] + XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Title field not found in WebView") + titleField.tap() + titleField.typeText(text) + return titleField + } + + /// Opens the block inserter and inserts a block by name. + static func insertBlock(_ name: String, webView: XCUIElement, app: XCUIApplication) { + let addBlockButton = webView.buttons["Add block"] + XCTAssertTrue(addBlockButton.waitForExistence(timeout: 10), "Add block button not found in WebView toolbar") + addBlockButton.tap() + + let blockOption = app.buttons[name] + XCTAssertTrue(blockOption.waitForExistence(timeout: 10), "\(name) block not found in block inserter") + blockOption.tap() + } + + /// Types text into the currently focused content block. + static func typeInContent(_ text: String, webView: XCUIElement) { + let block = webView.textViews["Empty block; start writing or type forward slash to choose a block"] + XCTAssertTrue(block.waitForExistence(timeout: 10), "Editable block not found in WebView") + block.typeText(text) + } +} + +extension XCUIElement { + /// Polls until `isEnabled` becomes `true` or the timeout expires. + func waitForEnabled(timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "isEnabled == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + return result == .completed + } +} From 7d08713f501e12c2176c00d070a74d4f6d87d113 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 09:29:48 -0500 Subject: [PATCH 13/21] test: Add editor content verification to iOS E2E tests Add helpers to read and assert on actual editor content by switching to Code Editor mode and reading textarea values. Update undo/redo and code editor toggle tests to verify content survives operations. --- .../xcschemes/GutenbergUITests.xcscheme | 3 +- .../EditorInteractionUITest.swift | 44 +++++--- .../EditorUITestHelpers.swift | 105 ++++++++++++++++++ 3 files changed, 135 insertions(+), 17 deletions(-) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme index e08440cd..0fc09e46 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme @@ -15,8 +15,7 @@ shouldAutocreateTestPlan = "YES"> + skipped = "NO"> String? { + let titleField = webView.textViews["Add title"] + guard titleField.waitForExistence(timeout: 10) else { + XCTFail("Title textarea not found in Code Editor mode") + return nil + } + return titleField.value as? String + } + + /// Reads the current raw HTML content from the code editor's content textarea. + /// The editor must already be in Code Editor mode. + static func readContent(webView: XCUIElement) -> String? { + let contentField = webView.textViews["Start writing with text or HTML"] + guard contentField.waitForExistence(timeout: 10) else { + XCTFail("Content textarea not found in Code Editor mode") + return nil + } + return contentField.value as? String + } + + // MARK: - Content Assertion Helpers + + /// Switches to Code Editor mode, reads the title, then switches back to Visual Editor. + /// Asserts the title equals the expected string. + @discardableResult + static func assertTitle( + equals expected: String, + webView: XCUIElement, + app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) -> String? { + switchToCodeEditor(app: app) + let title = readTitle(webView: webView) + switchToVisualEditor(app: app) + XCTAssertEqual(title, expected, "Title mismatch", file: file, line: line) + return title + } + + /// Switches to Code Editor, reads content, then switches back. + /// Asserts the HTML content contains the expected substring. + @discardableResult + static func assertContentContains( + _ expected: String, + webView: XCUIElement, + app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) -> String? { + switchToCodeEditor(app: app) + let content = readContent(webView: webView) + switchToVisualEditor(app: app) + if let content { + XCTAssertTrue( + content.contains(expected), + "Expected content to contain \"\(expected)\" but got \"\(content)\"", + file: file, + line: line + ) + } + return content + } + + /// Switches to Code Editor, reads both title and content, then switches back. + /// Returns (title, content) tuple for custom assertions. + static func readTitleAndContent( + webView: XCUIElement, + app: XCUIApplication + ) -> (title: String, content: String)? { + switchToCodeEditor(app: app) + let title = readTitle(webView: webView) + let content = readContent(webView: webView) + switchToVisualEditor(app: app) + guard let title, let content else { return nil } + return (title: title, content: content) + } } extension XCUIElement { From 7a306fb304bc3bd6b364bcc9e0bdd97c2bd924ce Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 09:35:14 -0500 Subject: [PATCH 14/21] refactor: Read title and content in a single mode toggle Replace separate assertTitle/assertContentContains helpers (which each toggled to Code Editor independently) with a unified assertContent helper that reads both values in one toggle. --- .../EditorInteractionUITest.swift | 46 +++++--------- .../EditorUITestHelpers.swift | 60 ++++++++----------- 2 files changed, 40 insertions(+), 66 deletions(-) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index a44b6f5a..ad134574 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -71,8 +71,7 @@ final class EditorInteractionUITest: XCTestCase { XCTAssertTrue(undoButton.isEnabled, "Undo should remain enabled after typing in content") // Verify content before undo. - EditorUITestHelpers.assertTitle(equals: "Hello", webView: webView, app: app) - EditorUITestHelpers.assertContentContains("World", webView: webView, app: app) + EditorUITestHelpers.assertContent(expectedTitle: "Hello", expectedContentSubstring: "World", webView: webView, app: app) // Tap undo — redo should become enabled. undoButton.tap() @@ -83,8 +82,7 @@ final class EditorInteractionUITest: XCTestCase { XCTAssertTrue(undoButton.waitForEnabled(timeout: 10), "Undo should be enabled after redoing") // Verify content is restored after redo. - EditorUITestHelpers.assertTitle(equals: "Hello", webView: webView, app: app) - EditorUITestHelpers.assertContentContains("World", webView: webView, app: app) + EditorUITestHelpers.assertContent(expectedTitle: "Hello", expectedContentSubstring: "World", webView: webView, app: app) } // MARK: - Editor Mode @@ -103,33 +101,21 @@ final class EditorInteractionUITest: XCTestCase { EditorUITestHelpers.insertBlock("Paragraph", webView: webView, app: app) EditorUITestHelpers.typeInContent("Test content", webView: webView) - // Switch to Code Editor and verify content is visible in the textareas. - EditorUITestHelpers.switchToCodeEditor(app: app) - XCTAssertTrue(webView.waitForExistence(timeout: 10), "WebView disappeared after switching to Code Editor") - - let title = EditorUITestHelpers.readTitle(webView: webView) - XCTAssertEqual(title, "Test Title", "Title should be visible in Code Editor mode") - - let content = EditorUITestHelpers.readContent(webView: webView) - XCTAssertNotNil(content, "Content should be readable in Code Editor mode") - XCTAssertTrue(content?.contains("Test content") == true, "Content should contain typed text") - - // Switch back to Visual Editor. - EditorUITestHelpers.switchToVisualEditor(app: app) - XCTAssertTrue(webView.waitForExistence(timeout: 10), "WebView disappeared after switching to Visual Editor") - - // Switch to Code Editor again to verify content survives the round-trip. - EditorUITestHelpers.switchToCodeEditor(app: app) - - let titleAfterRoundTrip = EditorUITestHelpers.readTitle(webView: webView) - XCTAssertEqual(titleAfterRoundTrip, "Test Title", "Title should survive Code Editor round-trip") - - let contentAfterRoundTrip = EditorUITestHelpers.readContent(webView: webView) - XCTAssertTrue(contentAfterRoundTrip?.contains("Test content") == true, "Content should survive Code Editor round-trip") + // Switch to Code Editor, verify content, then switch back (single toggle). + EditorUITestHelpers.assertContent( + expectedTitle: "Test Title", + expectedContentSubstring: "Test content", + webView: webView, + app: app + ) - // Switch back to Visual Editor to leave in a clean state. - EditorUITestHelpers.switchToVisualEditor(app: app) - XCTAssertTrue(webView.waitForExistence(timeout: 10), "WebView disappeared after final switch to Visual Editor") + // Switch again to verify content survives a second round-trip. + EditorUITestHelpers.assertContent( + expectedTitle: "Test Title", + expectedContentSubstring: "Test content", + webView: webView, + app: app + ) } // MARK: - Block Inserter diff --git a/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift index 3fef2e3a..f0dc5ccd 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift @@ -105,59 +105,47 @@ enum EditorUITestHelpers { // MARK: - Content Assertion Helpers - /// Switches to Code Editor mode, reads the title, then switches back to Visual Editor. - /// Asserts the title equals the expected string. + /// Switches to Code Editor, reads both title and content, then switches back. + /// Returns (title, content) tuple for custom assertions. @discardableResult - static func assertTitle( - equals expected: String, + static func readTitleAndContent( webView: XCUIElement, - app: XCUIApplication, - file: StaticString = #filePath, - line: UInt = #line - ) -> String? { + app: XCUIApplication + ) -> (title: String, content: String)? { switchToCodeEditor(app: app) let title = readTitle(webView: webView) + let content = readContent(webView: webView) switchToVisualEditor(app: app) - XCTAssertEqual(title, expected, "Title mismatch", file: file, line: line) - return title + guard let title, let content else { return nil } + return (title: title, content: content) } - /// Switches to Code Editor, reads content, then switches back. - /// Asserts the HTML content contains the expected substring. + /// Switches to Code Editor, reads both title and content, then switches back. + /// Optionally asserts the title equals `expectedTitle` and/or the content + /// contains `expectedContentSubstring`. Uses a single mode toggle regardless + /// of how many assertions are requested. @discardableResult - static func assertContentContains( - _ expected: String, + static func assertContent( + expectedTitle: String? = nil, + expectedContentSubstring: String? = nil, webView: XCUIElement, app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line - ) -> String? { - switchToCodeEditor(app: app) - let content = readContent(webView: webView) - switchToVisualEditor(app: app) - if let content { + ) -> (title: String, content: String)? { + let result = readTitleAndContent(webView: webView, app: app) + if let expectedTitle { + XCTAssertEqual(result?.title, expectedTitle, "Title mismatch", file: file, line: line) + } + if let expectedContentSubstring, let content = result?.content { XCTAssertTrue( - content.contains(expected), - "Expected content to contain \"\(expected)\" but got \"\(content)\"", + content.contains(expectedContentSubstring), + "Expected content to contain \"\(expectedContentSubstring)\" but got \"\(content)\"", file: file, line: line ) } - return content - } - - /// Switches to Code Editor, reads both title and content, then switches back. - /// Returns (title, content) tuple for custom assertions. - static func readTitleAndContent( - webView: XCUIElement, - app: XCUIApplication - ) -> (title: String, content: String)? { - switchToCodeEditor(app: app) - let title = readTitle(webView: webView) - let content = readContent(webView: webView) - switchToVisualEditor(app: app) - guard let title, let content else { return nil } - return (title: title, content: content) + return result } } From 19d016e387d749c5397663fc65431823f381cb17 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 09:37:46 -0500 Subject: [PATCH 15/21] test: Remove redundant code editor toggle test The mode toggle is now exercised implicitly by the content assertion helpers used in testUndoRedoAfterTyping, making the dedicated testCodeEditorToggleWithContent test superfluous. --- .../EditorInteractionUITest.swift | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index ad134574..88b6ff7a 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -85,39 +85,6 @@ final class EditorInteractionUITest: XCTestCase { EditorUITestHelpers.assertContent(expectedTitle: "Hello", expectedContentSubstring: "World", webView: webView, app: app) } - // MARK: - Editor Mode - - /// Type content in title and body, switch to code editor, then switch back. - /// - /// Exercises the native→JS bridge: toggling `isCodeEditorEnabled` - /// calls `editor.switchEditorMode()` in the WebView. The test - /// verifies the round-trip doesn't crash and the WebView survives - /// both transitions. - func testCodeEditorToggleWithContent() throws { - let webView = try EditorUITestHelpers.navigateToEditor(app: app) - - // Type content into the title, then insert a Paragraph and type in it. - EditorUITestHelpers.typeInTitle("Test Title", webView: webView) - EditorUITestHelpers.insertBlock("Paragraph", webView: webView, app: app) - EditorUITestHelpers.typeInContent("Test content", webView: webView) - - // Switch to Code Editor, verify content, then switch back (single toggle). - EditorUITestHelpers.assertContent( - expectedTitle: "Test Title", - expectedContentSubstring: "Test content", - webView: webView, - app: app - ) - - // Switch again to verify content survives a second round-trip. - EditorUITestHelpers.assertContent( - expectedTitle: "Test Title", - expectedContentSubstring: "Test content", - webView: webView, - app: app - ) - } - // MARK: - Block Inserter /// Open the block inserter and insert an Image block. From 34496f93c842d05de5e85cd6860d66729a385e50 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sun, 15 Feb 2026 09:43:31 -0500 Subject: [PATCH 16/21] test: Verify content after undo in undo/redo test Assert that the undone text is no longer present in the editor content between the undo and redo steps. --- .../GutenbergUITests/EditorInteractionUITest.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index 88b6ff7a..25cf3e4f 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -77,6 +77,13 @@ final class EditorInteractionUITest: XCTestCase { undoButton.tap() XCTAssertTrue(redoButton.waitForEnabled(timeout: 10), "Redo should be enabled after undoing") + // Verify the last typed text was undone. + let afterUndo = EditorUITestHelpers.readTitleAndContent(webView: webView, app: app) + XCTAssertNotNil(afterUndo, "Should be able to read content after undo") + if let content = afterUndo?.content { + XCTAssertFalse(content.contains("World"), "Content should not contain undone text") + } + // Tap redo — redo should become disabled and undo should remain enabled. redoButton.tap() XCTAssertTrue(undoButton.waitForEnabled(timeout: 10), "Undo should be enabled after redoing") From 3f9497ee3733b470cc177650a148d5ffabff39a0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Feb 2026 14:21:06 -0500 Subject: [PATCH 17/21] feat: Add dev server support for iOS E2E tests Add `make test-ios-e2e-dev` target that runs iOS E2E tests against the Vite dev server for faster local iteration without a production build. Uses the `TEST_RUNNER_` xcodebuild convention to forward `GUTENBERG_EDITOR_URL` to the test runner process. --- Makefile | 21 +++++++++- docs/code/testing.md | 42 +++++++++++++++++-- .../EditorInteractionUITest.swift | 8 ++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 59359651..09f51144 100644 --- a/Makefile +++ b/Makefile @@ -190,8 +190,8 @@ test-swift-package: build ## Run Swift package tests $(call XCODEBUILD_CMD, test) .PHONY: test-ios-e2e -test-ios-e2e: build ## Run iOS E2E tests (requires Xcode and a simulator) - @echo "--- :ios: Running iOS E2E Tests" +test-ios-e2e: build ## Run iOS E2E tests against the production build + @echo "--- :ios: Running iOS E2E Tests (production build)" @set -o pipefail && \ xcodebuild test \ -project ./ios/Demo-iOS/Gutenberg.xcodeproj \ @@ -200,6 +200,23 @@ test-ios-e2e: build ## Run iOS E2E tests (requires Xcode and a simulator) -destination '${SIMULATOR_DESTINATION}' \ | xcbeautify +.PHONY: test-ios-e2e-dev +test-ios-e2e-dev: npm-dependencies ## Run iOS 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 + @echo "--- :ios: Running iOS E2E Tests (dev server)" + @set -o pipefail && \ + TEST_RUNNER_GUTENBERG_EDITOR_URL=http://localhost:5173 \ + xcodebuild test \ + -project ./ios/Demo-iOS/Gutenberg.xcodeproj \ + -scheme GutenbergUITests \ + -sdk iphonesimulator \ + -destination '${SIMULATOR_DESTINATION}' \ + | xcbeautify + .PHONY: test-android test-android: ## Run Android tests @echo "--- :android: Running Android Tests" diff --git a/docs/code/testing.md b/docs/code/testing.md index e64923cd..35fb72ac 100644 --- a/docs/code/testing.md +++ b/docs/code/testing.md @@ -54,14 +54,50 @@ make test-e2e-ui These tests launch the Demo iOS app via `XCUIApplication` and verify native shell behavior — toolbar rendering, menu interactions, WebView lifecycle, and native-to-JS bridge state synchronization. -To run the iOS E2E tests: +There are two ways to run the tests, depending on how the editor JS is served. + +#### Dev server (local development) + +Uses the Vite dev server for faster iteration — no production build required. Start the dev server in one terminal, then run the tests in another: + +```bash +# Terminal 1 +make dev-server + +# Terminal 2 +make test-ios-e2e-dev +``` + +This sets `TEST_RUNNER_GUTENBERG_EDITOR_URL`, which `xcodebuild` forwards to the test runner process (with the `TEST_RUNNER_` prefix stripped). The test setup then passes `GUTENBERG_EDITOR_URL` to the app under test via `launchEnvironment`, so the WebView loads from `http://localhost:5173` instead of the bundled assets. + +#### Production build (CI) + +Uses the production JS bundle built by Vite. This is what CI runs and is the default `make test-ios-e2e` target: ```bash make test-ios-e2e ``` -> **Note:** The web editor must be built first (`make build`). The E2E -> target depends on `build` and will handle this automatically. +The target depends on `build` and will handle it automatically. + +#### Switching between modes + +The mode is controlled by the `GUTENBERG_EDITOR_URL` environment variable. When set, `EditorViewController` loads from that URL; otherwise it loads from the bundled `index.html`. + +- `make test-ios-e2e-dev` — sets `TEST_RUNNER_GUTENBERG_EDITOR_URL=http://localhost:5173` and checks that the dev server is running before starting. +- `make test-ios-e2e` — does not set the variable; runs a production build first. + +You can also pass the variable directly if you need a custom URL: + +```bash +TEST_RUNNER_GUTENBERG_EDITOR_URL=http://localhost:5173 xcodebuild test \ + -project ./ios/Demo-iOS/Gutenberg.xcodeproj \ + -scheme GutenbergUITests \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +> **Note:** The `TEST_RUNNER_` prefix is an `xcodebuild` convention — variables with this prefix are forwarded to test runner processes with the prefix removed. ## Code Quality diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift index 25cf3e4f..e67f3035 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -17,6 +17,14 @@ final class EditorInteractionUITest: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false app = XCUIApplication() + + // Forward the dev server URL to the app under test when set. + // This lets `EditorViewController` load from the Vite dev server + // instead of the bundled production build. + if let devServerURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"] { + app.launchEnvironment["GUTENBERG_EDITOR_URL"] = devServerURL + } + app.launch() } From 02c06927887e11395e3fb4c37d664857d048512c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Feb 2026 14:26:28 -0500 Subject: [PATCH 18/21] docs: Add Web E2E subsection and clarify local vs CI modes --- docs/code/testing.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/code/testing.md b/docs/code/testing.md index 35fb72ac..f41abe20 100644 --- a/docs/code/testing.md +++ b/docs/code/testing.md @@ -30,10 +30,14 @@ make test-android ## E2E Tests -E2E tests use Playwright to load the editor in a headless Chromium browser and verify Gutenberg editor logic — block operations, text formatting, split/merge, and data store state. They run against the Vite dev server with no native layer involved. +### Web E2E Tests (Playwright) + +These tests use Playwright to load the editor in a headless Chromium browser and verify Gutenberg editor logic — block operations, text formatting, split/merge, and data store state. No native layer is involved. Test files live in `e2e/*.spec.js`. +Locally, Playwright starts the Vite dev server (`npm run dev` on `:5173`) and reuses an existing one if already running. On CI, it uses a production preview build (`npm run preview` on `:4173`) with serial workers and retries. This is configured in `playwright.config.js`. + Run tests: ```bash @@ -46,7 +50,7 @@ Run in interactive UI mode: make test-e2e-ui ``` -### iOS E2E Tests +### iOS E2E Tests (XCUITest) - Framework: XCUITest - Test files: `ios/Demo-iOS/GutenbergUITests/` From 20d4626017bb4a4030f830be8684b385ac4d369b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Feb 2026 14:39:02 -0500 Subject: [PATCH 19/21] ci: Add Buildkite step for iOS E2E tests Align test-ios-e2e with test-e2e by using a conditional build instead of a Make dependency, avoiding unnecessary npm and translation steps when dist/ already exists. Add corresponding Buildkite pipeline step. --- .buildkite/pipeline.yml | 8 ++++++++ Makefile | 11 ++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index f9df16ea..846f4597 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -53,3 +53,11 @@ steps: - label: ':swift: Test Swift Package' command: swift test plugins: *plugins + + - label: ':ios: Test iOS E2E' + depends_on: build-react + command: | + buildkite-agent artifact download dist.tar.gz . + tar -xzf dist.tar.gz + make test-ios-e2e + plugins: *plugins diff --git a/Makefile b/Makefile index 09f51144..cdbc88a9 100644 --- a/Makefile +++ b/Makefile @@ -190,7 +190,16 @@ test-swift-package: build ## Run Swift package tests $(call XCODEBUILD_CMD, test) .PHONY: test-ios-e2e -test-ios-e2e: build ## Run iOS E2E tests against the production build +test-ios-e2e: ## Run iOS 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 "./ios/Sources/GutenbergKit/Gutenberg" ]; then \ + echo "--- :open_file_folder: Copying build into iOS bundle"; \ + cp -r ./dist/. ./ios/Sources/GutenbergKit/Gutenberg/; \ + fi @echo "--- :ios: Running iOS E2E Tests (production build)" @set -o pipefail && \ xcodebuild test \ From 6a7197fb00e302651e65577dc1b05bd555ac0333 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Feb 2026 14:39:44 -0500 Subject: [PATCH 20/21] refactor: Remove unnecessary npm-dependencies from test-ios-e2e-dev --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index cdbc88a9..cc52f20b 100644 --- a/Makefile +++ b/Makefile @@ -210,7 +210,7 @@ test-ios-e2e: ## Run iOS E2E tests against the production build | xcbeautify .PHONY: test-ios-e2e-dev -test-ios-e2e-dev: npm-dependencies ## Run iOS E2E tests against the Vite dev server (must be running) +test-ios-e2e-dev: ## Run iOS 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"; \ From f070c76dd6647f618ad6b5954d96648d94e08b40 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Feb 2026 14:49:04 -0500 Subject: [PATCH 21/21] ci: Rename Buildkite E2E step to Test Web E2E --- .buildkite/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 846f4597..29190801 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -23,7 +23,7 @@ steps: command: make test-js plugins: *plugins - - label: ':performing_arts: Test E2E' + - label: ':performing_arts: Test Web E2E' depends_on: build-react command: | buildkite-agent artifact download dist.tar.gz .