diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index f9df16ea6..291908010 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 . @@ -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 e070fb75b..cc52f20bb 100644 --- a/Makefile +++ b/Makefile @@ -189,6 +189,43 @@ 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: ## 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 \ + -project ./ios/Demo-iOS/Gutenberg.xcodeproj \ + -scheme GutenbergUITests \ + -sdk iphonesimulator \ + -destination '${SIMULATOR_DESTINATION}' \ + | xcbeautify + +.PHONY: test-ios-e2e-dev +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"; \ + 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 e56c70e7e..f41abe203 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,6 +50,59 @@ Run in interactive UI mode: make test-e2e-ui ``` +### iOS E2E Tests (XCUITest) + +- 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. + +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 +``` + +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 Before submitting a pull request, ensure your code passes formatting and linting checks. diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index def52d87f..ebe8102d3 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/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme new file mode 100644 index 000000000..0fc09e468 --- /dev/null +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/GutenbergUITests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift new file mode 100644 index 000000000..e67f3035b --- /dev/null +++ b/ios/Demo-iOS/GutenbergUITests/EditorInteractionUITest.swift @@ -0,0 +1,124 @@ +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() + + // 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() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Editor Loading + + /// A WebView becomes visible after the editor finishes loading. + func testEditorWebViewBecomesVisible() throws { + try EditorUITestHelpers.navigateToEditor(app: app) + } + + // MARK: - Editor History + + /// Typing in the title and content enables undo; tapping undo enables redo. + /// + /// Exercises the full bridge round-trip: + /// 1. Verify undo/redo are disabled on a fresh editor + /// 2. Type text in title → bridge sends `onEditorHistoryChanged` with `hasUndo: true` + /// 3. 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 EditorUITestHelpers.navigateToEditor(app: app) + + 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 + } + + // 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") + + // Type in the title field. + 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. + 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") + + // Verify content before undo. + EditorUITestHelpers.assertContent(expectedTitle: "Hello", expectedContentSubstring: "World", webView: webView, app: app) + + // Tap undo — redo should become enabled. + 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") + + // Verify content is restored after redo. + EditorUITestHelpers.assertContent(expectedTitle: "Hello", expectedContentSubstring: "World", webView: webView, app: app) + } + + // 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 EditorUITestHelpers.navigateToEditor(app: app) + + EditorUITestHelpers.insertBlock("Image", webView: webView, app: app) + + // After insertion, an Image block should appear in the editor. + let imageBlockInEditor = webView.buttons["Upload"] + XCTAssertTrue( + imageBlockInEditor.waitForExistence(timeout: 10), + "Image block placeholder not found in editor after insertion" + ) + } +} diff --git a/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift new file mode 100644 index 000000000..f0dc5ccd6 --- /dev/null +++ b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift @@ -0,0 +1,160 @@ +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) + } + + // MARK: - Mode Switching + + /// Switches the editor to Code Editor mode via the More menu. + static func switchToCodeEditor(app: XCUIApplication) { + 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() + } + + /// Switches the editor back to Visual Editor mode via the More menu. + static func switchToVisualEditor(app: XCUIApplication) { + let moreButton = app.buttons["More"] + XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "More button not found") + moreButton.tap() + + let visualEditorButton = app.buttons["Visual Editor"] + XCTAssertTrue(visualEditorButton.waitForExistence(timeout: 5), "Visual Editor button not found in menu") + visualEditorButton.tap() + } + + // MARK: - Content Reading (Code Editor Mode) + + /// Reads the current title from the code editor's title textarea. + /// The editor must already be in Code Editor mode. + static func readTitle(webView: XCUIElement) -> 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, reads both title and content, then switches back. + /// Returns (title, content) tuple for custom assertions. + @discardableResult + 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) + } + + /// 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 assertContent( + expectedTitle: String? = nil, + expectedContentSubstring: String? = nil, + webView: XCUIElement, + app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) -> (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(expectedContentSubstring), + "Expected content to contain \"\(expectedContentSubstring)\" but got \"\(content)\"", + file: file, + line: line + ) + } + return result + } +} + +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/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 84d96ecab..6345c8dc5 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") } }