import XCTest // End-to-end drive of the real app against a live server: join via a member // invite link, land in the DM, send a message, and see the other member's live // reply arrive over the socket. The server URL/invite ride in via env // (TEST_RUNNER_INVITE_LINK etc. on the xcodebuild invocation); a scripted peer // ("Paula") must be listening server-side to produce the reply. final class JoinAndChatTests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false } func testJoinThreadListSendReceive() throws { let env = ProcessInfo.processInfo.environment guard let invite = env["INVITE_LINK"], !invite.isEmpty else { throw XCTSkip("Set TEST_RUNNER_INVITE_LINK to a /join/… link for a live server.") } let outgoing = env["SEND_TEXT"] ?? "hello from Eleven on iOS" let expectedReply = env["EXPECT_REPLY"] ?? "hello back from Paula" let app = XCUIApplication() app.launch() // Fresh install → empty state → "Add a chat" let add = app.buttons["Add a chat"].firstMatch XCTAssertTrue(add.waitForExistence(timeout: 10), "no Add a chat button") shoot(app, "01-empty-state") add.tap() // Paste the invite link — a valid link starts the join on its own (no // button), matching the Mac app. let field = app.textFields.firstMatch XCTAssertTrue(field.waitForExistence(timeout: 5), "no invite link field") field.tap() field.typeText(invite) // The web join form: screen name is prefilled from the invite label. let join = webElement(app, "Join the chat") XCTAssertTrue(join.waitForExistence(timeout: 20), "web join form never appeared") shoot(app, "02-join-form") join.tap() // Back in native: the thread list shows the DM with the inviter. let paula = app.staticTexts["Paula"].firstMatch XCTAssertTrue(paula.waitForExistence(timeout: 25), "DM with Paula not in thread list") shoot(app, "03-thread-list") paula.tap() // The feed opens; type into the native composer and send. let composer = app.textViews.firstMatch XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer") composer.tap() composer.typeText(outgoing) let send = app.buttons["Send"].firstMatch XCTAssertTrue(send.waitForExistence(timeout: 5), "no send button") send.tap() // Our message renders in the webview feed… let mine = webElement(app, outgoing) XCTAssertTrue(mine.waitForExistence(timeout: 20), "sent message never rendered") shoot(app, "04-message-sent") // …and Paula's scripted reply arrives live over the socket. let reply = webElement(app, expectedReply) XCTAssertTrue(reply.waitForExistence(timeout: 60), "Paula's reply never rendered") shoot(app, "05-reply-received") } // Approve the notification-permission alert (springboard-owned) so push // verification can deliver banners. Run once after a join. func testGrantNotifications() throws { guard ProcessInfo.processInfo.environment["GRANT_PUSH"] == "1" else { throw XCTSkip("Set TEST_RUNNER_GRANT_PUSH=1 to run the permission grant.") } let app = XCUIApplication() app.launch() let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let allow = springboard.buttons["Allow"].firstMatch if allow.waitForExistence(timeout: 10) { allow.tap() } // already granted otherwise sleep(1) } // Screens-only pass over an already-joined install: open the first chat, // raise the keyboard, screenshot each state. For eyeballing layout work // (floating bars, insets) without burning an invite. func testOpenExistingChatScreens() throws { guard ProcessInfo.processInfo.environment["OPEN_ONLY"] == "1" else { throw XCTSkip("Set TEST_RUNNER_OPEN_ONLY=1 (with a joined install) for the screens pass.") } let app = XCUIApplication() app.launch() let row = app.staticTexts["Paula"].firstMatch XCTAssertTrue(row.waitForExistence(timeout: 15), "no Paula chat on this install") // Edge-drag pulls the server drawer in under the finger; drag it back shut. let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5)) let middle = app.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: 0.5)) edge.press(forDuration: 0.05, thenDragTo: middle, withVelocity: .slow, thenHoldForDuration: 0.1) let serversTitle = app.staticTexts["server-drawer-title"].firstMatch XCTAssertTrue(serversTitle.waitForExistence(timeout: 3) && serversTitle.isHittable, "drawer didn't open from edge drag") shoot(app, "15-drawer-dragged") // Close by dragging from the scrim (the drawer panel's scroll view owns // gestures that start inside it; the scrim also closes on a plain tap). let scrim = app.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)) scrim.press(forDuration: 0.2, thenDragTo: edge, withVelocity: .slow, thenHoldForDuration: 0.2) sleep(1) shoot(app, "16-drawer-closed") // Offscreen elements never leave the XCUITest tree — `exists` stays true // forever; hittability is what actually flips when the drawer slides out. let hidden = NSPredicate(format: "isHittable == false") let closed = XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: hidden, object: serversTitle)], timeout: 3) == .completed XCTAssertTrue(closed, "drawer didn't close from drag") row.tap() let composer = app.textViews.firstMatch XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer") sleep(2) // let the feed render + settle shoot(app, "10-chat-floating") // Swipe-right on a message → the native reply banner appears; ✕ clears it. let bubble = webElement(app, "hello back from Paula") if bubble.waitForExistence(timeout: 5) { bubble.swipeRight() let banner = app.staticTexts["Replying to Paula"].firstMatch XCTAssertTrue(banner.waitForExistence(timeout: 5), "reply banner never appeared") shoot(app, "12-reply-banner") app.buttons["Cancel reply"].firstMatch.tap() XCTAssertTrue(waitGone(banner, timeout: 5), "reply banner didn't clear") } composer.tap() sleep(2) // keyboard animation shoot(app, "11-chat-keyboard") // :emoji typeahead — the strip appears with ranked glyphs; a tap splices. composer.typeText(":tada") let emojiChip = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", ":tada:")).firstMatch XCTAssertTrue(emojiChip.waitForExistence(timeout: 5), "emoji typeahead never appeared") shoot(app, "13-emoji-typeahead") emojiChip.tap() XCTAssertTrue(((composer.value as? String) ?? "").contains("🎉"), "emoji wasn't spliced in") // @mention typeahead over the roster. composer.typeText(" @Pa") let mentionChip = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Paula")).firstMatch XCTAssertTrue(mentionChip.waitForExistence(timeout: 5), "mention typeahead never appeared") shoot(app, "14-mention-typeahead") mentionChip.tap() XCTAssertTrue(((composer.value as? String) ?? "").contains("@Paula"), "mention wasn't spliced in") } private func waitGone(_ element: XCUIElement, timeout: TimeInterval) -> Bool { let p = NSPredicate(format: "exists == false") return XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: p, object: element)], timeout: timeout) == .completed } // Web content exposes elements inconsistently (button vs link vs static text // depending on markup) — match any descendant by label. private func webElement(_ app: XCUIApplication, _ label: String) -> XCUIElement { app.webViews.descendants(matching: .any).matching( NSPredicate(format: "label CONTAINS %@", label) ).firstMatch } private func shoot(_ app: XCUIApplication, _ name: String) { let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = name shot.lifetime = .keepAlways add(shot) } }