When pasting an npub or nprofile into the post composer, automatically convert it to a human-readable mention link. If the profile isn't cached locally, fetch it from relays and update the mention display name when it arrives. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Changelog-Added: Added automatic conversion of pasted npub/nprofile to human-readable mentions in post composer Closes: https://github.com/damus-io/damus/issues/2289 Closes: https://github.com/damus-io/damus/pull/3473 Co-Authored-By: Claude Opus 4.5 Tested-by: William Casarin <jb55@jb55.com> Signed-off-by: alltheseas Reviewed-by: William Casarin <jb55@jb55.com>
437 lines
20 KiB
Swift
437 lines
20 KiB
Swift
//
|
||
// damusUITests.swift
|
||
// damusUITests
|
||
//
|
||
// Created by William Casarin on 2022-04-01.
|
||
//
|
||
|
||
import XCTest
|
||
import UIKit
|
||
|
||
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()
|
||
}
|
||
|
||
/// Tests that typing before a mention doesn't break the mention.
|
||
/// This guards against regressions like https://github.com/damus-io/damus/issues/3460
|
||
/// where inserting text before a mention would unlink it.
|
||
///
|
||
/// The test creates a real mention link by selecting from autocomplete,
|
||
/// then types text before it and verifies the mention text is preserved.
|
||
/// Note: Link attribute preservation is verified in unit tests (PostViewTests).
|
||
func testTypingBeforeMentionPreservesMention() throws {
|
||
try self.loginIfNotAlready()
|
||
|
||
// Open post composer
|
||
guard app.buttons[AID.post_button.rawValue].waitForExistence(timeout: 10) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
app.buttons[AID.post_button.rawValue].tap()
|
||
|
||
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 "@" to trigger mention autocomplete
|
||
textView.typeText("@")
|
||
|
||
// Wait for autocomplete results to appear and tap on a user to create a real mention link
|
||
let mentionResult = app.otherElements[AID.post_composer_mention_user_result.rawValue].firstMatch
|
||
guard mentionResult.waitForExistence(timeout: 5) else {
|
||
// If no autocomplete results (no contacts loaded), skip this test gracefully
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
|
||
throw XCTSkip("No mention autocomplete results available - contacts may not be loaded")
|
||
}
|
||
mentionResult.tap()
|
||
|
||
// Wait for mention to be inserted (text should contain more than just "@")
|
||
let mentionInsertedPredicate = NSPredicate(format: "value CONTAINS[c] '@' AND value.length > 1")
|
||
let mentionInserted = expectation(for: mentionInsertedPredicate, evaluatedWith: textView)
|
||
wait(for: [mentionInserted], timeout: 3)
|
||
|
||
// Get the current text which should contain the mention (e.g., "@username ")
|
||
let textAfterMention = textView.value as? String ?? ""
|
||
XCTAssertTrue(textAfterMention.contains("@"),
|
||
"Text should contain a mention after selection but was '\(textAfterMention)'")
|
||
|
||
// Move cursor to the beginning and type a prefix
|
||
let startCoordinate = textView.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.5))
|
||
startCoordinate.tap()
|
||
|
||
// Type prefix text before the mention
|
||
textView.typeText("Hey ")
|
||
|
||
// Wait for the prefix to be inserted
|
||
let prefixInsertedPredicate = NSPredicate(format: "value BEGINSWITH 'Hey '")
|
||
let prefixInserted = expectation(for: prefixInsertedPredicate, evaluatedWith: textView)
|
||
wait(for: [prefixInserted], timeout: 3)
|
||
|
||
// Verify the text contains both the prefix and the mention is preserved
|
||
let finalText = textView.value as? String ?? ""
|
||
XCTAssertTrue(finalText.hasPrefix("Hey "),
|
||
"Text should start with 'Hey ' but was '\(finalText)'")
|
||
XCTAssertTrue(finalText.contains("@"),
|
||
"Text should still contain the mention '@' but was '\(finalText)'")
|
||
|
||
// Cancel to clean up
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
|
||
}
|
||
|
||
/// Tests that pasting an npub into the post composer converts it to a mention
|
||
/// and resolves to a human-readable profile name via async fetch.
|
||
/// This guards against regressions in https://github.com/damus-io/damus/issues/2289
|
||
func testPastedNpubResolvesToProfileName() throws {
|
||
try self.loginIfNotAlready()
|
||
|
||
// Set up interruption handler for iOS paste permission alerts
|
||
// iOS 16+ may show "Allow Paste" system alerts when pasting from other apps
|
||
addUIInterruptionMonitor(withDescription: "Paste Permission Alert") { alert in
|
||
// Handle both English and common localizations of the "Allow Paste" button
|
||
let allowButtons = ["Allow Paste", "Paste", "Allow", "Erlauben", "Autoriser", "許可"]
|
||
for buttonLabel in allowButtons {
|
||
let button = alert.buttons[buttonLabel]
|
||
if button.exists {
|
||
button.tap()
|
||
return true
|
||
}
|
||
}
|
||
// Try first button as fallback (typically the "allow" action)
|
||
if alert.buttons.count > 0 {
|
||
alert.buttons.element(boundBy: 0).tap()
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Open post composer
|
||
guard app.buttons[AID.post_button.rawValue].waitForExistence(timeout: 10) else {
|
||
throw DamusUITestError.timeout_waiting_for_element
|
||
}
|
||
app.buttons[AID.post_button.rawValue].tap()
|
||
|
||
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()
|
||
|
||
// Use a well-known npub (jack dorsey) that should resolve to a profile name
|
||
let testNpub = "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m"
|
||
|
||
// Put npub in pasteboard
|
||
UIPasteboard.general.string = testNpub
|
||
|
||
// Long press to bring up paste menu
|
||
textView.press(forDuration: 1.0)
|
||
|
||
// Find paste menu item - handle localized variants
|
||
// iOS uses "Paste" in English but varies by locale
|
||
let pasteLabels = ["Paste", "Einfügen", "Coller", "Pegar", "Incolla", "ペースト", "貼り付け", "붙여넣기"]
|
||
var pasteButton: XCUIElement?
|
||
for label in pasteLabels {
|
||
let button = app.menuItems[label]
|
||
if button.waitForExistence(timeout: 0.5) {
|
||
pasteButton = button
|
||
break
|
||
}
|
||
}
|
||
|
||
guard let pasteButton = pasteButton else {
|
||
// Fallback: try first menu item if no known paste label found
|
||
let firstMenuItem = app.menuItems.firstMatch
|
||
if firstMenuItem.waitForExistence(timeout: 1) {
|
||
firstMenuItem.tap()
|
||
} else {
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
|
||
throw XCTSkip("Paste menu not available in this environment")
|
||
}
|
||
// Trigger interruption monitors by interacting with app
|
||
app.tap()
|
||
|
||
// Check if paste worked despite not finding the button
|
||
let checkText = textView.value as? String ?? ""
|
||
if !checkText.contains("@") && !checkText.contains("npub") {
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
|
||
throw XCTSkip("Could not trigger paste action")
|
||
}
|
||
// Paste worked via fallback - clean up and exit
|
||
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
|
||
return
|
||
}
|
||
|
||
pasteButton.tap()
|
||
|
||
// Trigger interruption monitors in case paste permission alert appeared
|
||
app.tap()
|
||
|
||
// Wait for initial mention to appear (should contain @ symbol)
|
||
let mentionAppearedPredicate = NSPredicate(format: "value CONTAINS[c] '@'")
|
||
let mentionAppeared = expectation(for: mentionAppearedPredicate, evaluatedWith: textView)
|
||
wait(for: [mentionAppeared], timeout: 5)
|
||
|
||
// Verify initial paste created a mention (may still show @npub... initially)
|
||
let initialText = textView.value as? String ?? ""
|
||
XCTAssertTrue(initialText.contains("@"),
|
||
"Pasted npub should create a mention but text was '\(initialText)'")
|
||
|
||
// Wait for async profile fetch to resolve the name (should NOT contain "npub1" after resolution)
|
||
// Give it up to 10 seconds for relay fetch
|
||
let profileResolvedPredicate = NSPredicate(format: "NOT (value CONTAINS[c] 'npub1')")
|
||
let profileResolved = expectation(for: profileResolvedPredicate, evaluatedWith: textView)
|
||
|
||
let result = XCTWaiter.wait(for: [profileResolved], timeout: 10)
|
||
|
||
let finalText = textView.value as? String ?? ""
|
||
|
||
if result == .timedOut {
|
||
// Profile didn't resolve - this could happen if offline or relay issues
|
||
// Still verify the npub was at least converted to a mention link
|
||
XCTAssertTrue(finalText.contains("@"),
|
||
"Text should contain a mention but was '\(finalText)'")
|
||
print("Note: Profile did not resolve within timeout. Text: '\(finalText)'")
|
||
} else {
|
||
// Profile resolved - verify it's a human-readable name
|
||
XCTAssertTrue(finalText.contains("@"),
|
||
"Text should contain a mention but was '\(finalText)'")
|
||
XCTAssertFalse(finalText.contains("npub1"),
|
||
"Mention should resolve to profile name, not show npub. Text: '\(finalText)'")
|
||
}
|
||
|
||
// Cancel 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
|
||
}
|
||
}
|