When composing a new note, the cursor would jump in front of the first letter after typing it. This occurred because multiple SwiftUI view updates (text change, placeholder removal, height change) could cause the cursor position to be incorrectly restored. The fix explicitly tracks the cursor position after each text change by calling updateCursorPosition, ensuring the correct position is always used regardless of view update timing. Refactored textViewDidChange to use early return pattern for clarity. Added UI test to guard against cursor position regressions in the post composer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Changelog-Fixed: Fixed cursor jumping behind first letter when typing a new note Closes: https://github.com/damus-io/damus/pull/3473 Closes: https://github.com/damus-io/damus/issues/3461 Co-Authored-By: Claude Opus 4.5 Tested-by: William Casarin <jb55@jb55.com> Signed-off-by: alltheseas Signed-off-by: William Casarin <jb55@jb55.com>
243 lines
11 KiB
Swift
243 lines
11 KiB
Swift
//
|
||
// damusUITests.swift
|
||
// damusUITests
|
||
//
|
||
// Created by William Casarin on 2022-04-01.
|
||
//
|
||
|
||
import XCTest
|
||
|
||
class damusUITests: XCTestCase {
|
||
var app = XCUIApplication()
|
||
typealias AID = AppAccessibilityIdentifiers
|
||
|
||
override func setUpWithError() throws {
|
||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||
self.app = XCUIApplication()
|
||
|
||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||
continueAfterFailure = false
|
||
|
||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||
|
||
// Set app language to English
|
||
app.launchArguments += ["-AppleLanguages", "(en)"]
|
||
app.launchArguments += ["-AppleLocale", "en_US"]
|
||
|
||
// Force portrait orientation
|
||
XCUIDevice.shared.orientation = .portrait
|
||
|
||
// Optional: Reset the device's orientation before each test
|
||
addTeardownBlock {
|
||
XCUIDevice.shared.orientation = .portrait
|
||
}
|
||
|
||
app.launch()
|
||
}
|
||
|
||
override func tearDownWithError() throws {
|
||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||
}
|
||
|
||
/// Tests if banner edit button is clickable.
|
||
/// Note: This is able to detect if the button is obscured by an invisible overlaying object.
|
||
/// See https://github.com/damus-io/damus/issues/2636 for the kind of issue this guards against.
|
||
func testEditBannerImage() throws {
|
||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||
try self.loginIfNotAlready()
|
||
|
||
guard app.buttons[AID.main_side_menu_button.rawValue].tapIfExists(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
|
||
guard app.buttons[AID.side_menu_profile_button.rawValue].tapIfExists(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
|
||
guard app.buttons[AID.own_profile_edit_button.rawValue].tapIfExists(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
|
||
|
||
guard app.buttons[AID.own_profile_banner_image_edit_button.rawValue].waitForExistence(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
|
||
let bannerEditButtonCoordinates = app.buttons[AID.own_profile_banner_image_edit_button.rawValue].coordinate(withNormalizedOffset: CGVector.zero).withOffset(CGVector(dx: 15, dy: 15))
|
||
bannerEditButtonCoordinates.tap()
|
||
|
||
guard app.buttons[AID.own_profile_banner_image_edit_from_url.rawValue].waitForExistence(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
|
||
}
|
||
|
||
/// Tests the sign up flow to ensure users can successfully create a new account.
|
||
/// This test verifies:
|
||
/// 1. The "Create account" button is accessible
|
||
/// 2. Users can enter their name and bio
|
||
/// 3. The "Next" button becomes enabled after entering required information
|
||
/// 4. Users reach the save keys screen
|
||
/// 5. Users can skip saving keys and complete onboarding
|
||
func testSignUpFlow() throws {
|
||
try logoutIfNotAlready()
|
||
|
||
// Verify we're on the initial screen with sign up option
|
||
guard app.buttons[AID.sign_up_option_button.rawValue].waitForExistence(timeout: 5) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
// Tap the create account button
|
||
app.buttons[AID.sign_up_option_button.rawValue].tap()
|
||
|
||
// Wait for the create account screen to appear
|
||
guard app.textFields[AID.sign_up_name_field.rawValue].waitForExistence(timeout: 5) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
// Enter name (required field)
|
||
let nameField = app.textFields[AID.sign_up_name_field.rawValue]
|
||
nameField.tap()
|
||
nameField.typeText("Test User")
|
||
|
||
// Enter bio (optional field)
|
||
let bioField = app.textFields[AID.sign_up_bio_field.rawValue]
|
||
bioField.tap()
|
||
bioField.typeText("This is a test bio")
|
||
|
||
// Verify the Next button is present and enabled
|
||
let nextButton = app.buttons[AID.sign_up_next_button.rawValue]
|
||
guard nextButton.waitForExistence(timeout: 5) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
// Tap Next to proceed to save keys screen
|
||
nextButton.tap()
|
||
|
||
// Verify we reached the save keys screen by checking for the save button
|
||
guard app.buttons[AID.sign_up_save_keys_button.rawValue].waitForExistence(timeout: 10) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
// Verify both save options are present
|
||
XCTAssertTrue(app.buttons[AID.sign_up_skip_save_keys_button.rawValue].exists,
|
||
"Skip save keys button should be visible")
|
||
|
||
// Tap "Not now" to skip saving keys and continue to onboarding
|
||
app.buttons[AID.sign_up_skip_save_keys_button.rawValue].tap()
|
||
|
||
// Go through onboarding flow (similar to loginIfNotAlready)
|
||
// Select an interest if the interests page appears
|
||
app.buttons[AID.onboarding_interest_option_button.rawValue].firstMatch.tapIfExists(timeout: 5)
|
||
app.buttons[AID.onboarding_interest_page_next_page.rawValue].tapIfExists(timeout: 5)
|
||
|
||
// Continue through content settings page
|
||
app.buttons[AID.onboarding_content_settings_page_next_page.rawValue].tapIfExists(timeout: 5)
|
||
|
||
// Skip any remaining onboarding sheets
|
||
app.buttons[AID.onboarding_sheet_skip_button.rawValue].tapIfExists(timeout: 5)
|
||
|
||
// Cancel post composer if it appears
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tapIfExists(timeout: 5)
|
||
|
||
// Verify we've reached the main app interface by checking for the side menu button
|
||
guard app.buttons[AID.main_side_menu_button.rawValue].waitForExistence(timeout: 10) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
}
|
||
|
||
func logoutIfNotAlready() throws {
|
||
// First, check if user is already logged in and logout if needed
|
||
if app.buttons[AID.main_side_menu_button.rawValue].waitForExistence(timeout: 5) {
|
||
// User is already logged in, need to logout first
|
||
try logout()
|
||
}
|
||
}
|
||
|
||
func logout() throws {
|
||
app.buttons[AID.main_side_menu_button.rawValue].tap()
|
||
|
||
guard app.buttons[AID.side_menu_logout_button.rawValue].waitForExistence(timeout: 5) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
app.buttons[AID.side_menu_logout_button.rawValue].tap()
|
||
|
||
// Handle logout confirmation dialog (system alert)
|
||
// Wait for the alert to appear
|
||
let alert = app.alerts.firstMatch
|
||
guard alert.waitForExistence(timeout: 5) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
// Tap the confirm button in the alert
|
||
let confirmButton = alert.buttons[AID.side_menu_logout_confirm_button.rawValue].firstMatch
|
||
guard confirmButton.waitForExistence(timeout: 5) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
confirmButton.tap()
|
||
|
||
// Wait a moment for logout to complete
|
||
sleep(2)
|
||
}
|
||
|
||
func loginIfNotAlready() throws {
|
||
if app.buttons[AID.sign_in_option_button.rawValue].waitForExistence(timeout: 5) {
|
||
try self.login()
|
||
}
|
||
|
||
app.buttons[AID.onboarding_interest_option_button.rawValue].firstMatch.tapIfExists(timeout: 5)
|
||
app.buttons[AID.onboarding_interest_page_next_page.rawValue].tapIfExists(timeout: 5)
|
||
app.buttons[AID.onboarding_content_settings_page_next_page.rawValue].tapIfExists(timeout: 5)
|
||
app.buttons[AID.onboarding_sheet_skip_button.rawValue].tapIfExists(timeout: 5)
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tapIfExists(timeout: 5)
|
||
}
|
||
|
||
func login() throws {
|
||
app.buttons[AID.sign_in_option_button.rawValue].tap()
|
||
|
||
guard app.secureTextFields[AID.sign_in_nsec_key_entry_field.rawValue].tapIfExists(timeout: 10) else { throw DamusUITestError.timeout_waiting_for_element }
|
||
app.typeText("nsec1vxvz8c7070d99njn0aqpcttljnzhfutt422l0r37yep7htesd0mq9p8fg2")
|
||
|
||
guard app.buttons[AID.sign_in_confirm_button.rawValue].tapIfExists(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
|
||
}
|
||
|
||
/// Tests that typing in the post composer works correctly, specifically that
|
||
/// the cursor position is maintained after typing each character.
|
||
/// This guards against regressions like https://github.com/damus-io/damus/issues/3461
|
||
/// where the cursor would jump to position 0 after typing the first character.
|
||
func testPostComposerCursorPosition() throws {
|
||
try self.loginIfNotAlready()
|
||
|
||
// Wait for main interface to load, then tap the post button (FAB)
|
||
guard app.buttons[AID.post_button.rawValue].waitForExistence(timeout: 10) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
app.buttons[AID.post_button.rawValue].tap()
|
||
|
||
// Wait for the post composer text view to appear
|
||
guard app.textViews[AID.post_composer_text_view.rawValue].waitForExistence(timeout: 5) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
|
||
let textView = app.textViews[AID.post_composer_text_view.rawValue]
|
||
textView.tap()
|
||
|
||
// Type a test string character by character
|
||
// If the cursor jumps to position 0 after the first character,
|
||
// the resulting text would be scrambled (e.g., "olleH" instead of "Hello")
|
||
let testString = "Hello"
|
||
textView.typeText(testString)
|
||
|
||
// Verify the text was typed correctly (not scrambled)
|
||
let actualText = textView.value as? String ?? ""
|
||
XCTAssertEqual(actualText, testString,
|
||
"Text should be '\(testString)' but was '\(actualText)'. " +
|
||
"This may indicate a cursor position bug.")
|
||
|
||
// Cancel the post to clean up
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
|
||
}
|
||
|
||
enum DamusUITestError: Error {
|
||
case timeout_waiting_for_element
|
||
}
|
||
}
|
||
|
||
extension XCUIElement {
|
||
@discardableResult
|
||
func tapIfExists(timeout: TimeInterval) -> Bool {
|
||
if self.waitForExistence(timeout: timeout) {
|
||
self.tap()
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
}
|