eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import XCTest
2
3// End-to-end drive of the real app against a live server: join via a member
4// invite link, land in the DM, send a message, and see the other member's live
5// reply arrive over the socket. The server URL/invite ride in via env
6// (TEST_RUNNER_INVITE_LINK etc. on the xcodebuild invocation); a scripted peer
7// ("Paula") must be listening server-side to produce the reply.
8final class JoinAndChatTests: XCTestCase {
9
10 override func setUpWithError() throws {
11 continueAfterFailure = false
12 }
13
14 func testJoinThreadListSendReceive() throws {
15 let env = ProcessInfo.processInfo.environment
16 guard let invite = env["INVITE_LINK"], !invite.isEmpty else {
17 throw XCTSkip("Set TEST_RUNNER_INVITE_LINK to a /join/… link for a live server.")
18 }
19 let outgoing = env["SEND_TEXT"] ?? "hello from Eleven on iOS"
20 let expectedReply = env["EXPECT_REPLY"] ?? "hello back from Paula"
21
22 let app = XCUIApplication()
23 app.launch()
24
25 // Fresh install → empty state → "Add a chat"
26 let add = app.buttons["Add a chat"].firstMatch
27 XCTAssertTrue(add.waitForExistence(timeout: 10), "no Add a chat button")
28 shoot(app, "01-empty-state")
29 add.tap()
30
31 // Paste the invite link — a valid link starts the join on its own (no
32 // button), matching the Mac app.
33 let field = app.textFields.firstMatch
34 XCTAssertTrue(field.waitForExistence(timeout: 5), "no invite link field")
35 field.tap()
36 field.typeText(invite)
37
38 // The web join form: screen name is prefilled from the invite label.
39 let join = webElement(app, "Join the chat")
40 XCTAssertTrue(join.waitForExistence(timeout: 20), "web join form never appeared")
41 shoot(app, "02-join-form")
42 join.tap()
43
44 // Back in native: the thread list shows the DM with the inviter.
45 let paula = app.staticTexts["Paula"].firstMatch
46 XCTAssertTrue(paula.waitForExistence(timeout: 25), "DM with Paula not in thread list")
47 shoot(app, "03-thread-list")
48 paula.tap()
49
50 // The feed opens; type into the native composer and send.
51 let composer = app.textViews.firstMatch
52 XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer")
53 composer.tap()
54 composer.typeText(outgoing)
55 let send = app.buttons["Send"].firstMatch
56 XCTAssertTrue(send.waitForExistence(timeout: 5), "no send button")
57 send.tap()
58
59 // Our message renders in the webview feed…
60 let mine = webElement(app, outgoing)
61 XCTAssertTrue(mine.waitForExistence(timeout: 20), "sent message never rendered")
62 shoot(app, "04-message-sent")
63
64 // …and Paula's scripted reply arrives live over the socket.
65 let reply = webElement(app, expectedReply)
66 XCTAssertTrue(reply.waitForExistence(timeout: 60), "Paula's reply never rendered")
67 shoot(app, "05-reply-received")
68 }
69
70 // Approve the notification-permission alert (springboard-owned) so push
71 // verification can deliver banners. Run once after a join.
72 func testGrantNotifications() throws {
73 guard ProcessInfo.processInfo.environment["GRANT_PUSH"] == "1" else {
74 throw XCTSkip("Set TEST_RUNNER_GRANT_PUSH=1 to run the permission grant.")
75 }
76 let app = XCUIApplication()
77 app.launch()
78 let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
79 let allow = springboard.buttons["Allow"].firstMatch
80 if allow.waitForExistence(timeout: 10) { allow.tap() } // already granted otherwise
81 sleep(1)
82 }
83
84 // Screens-only pass over an already-joined install: open the first chat,
85 // raise the keyboard, screenshot each state. For eyeballing layout work
86 // (floating bars, insets) without burning an invite.
87 func testOpenExistingChatScreens() throws {
88 guard ProcessInfo.processInfo.environment["OPEN_ONLY"] == "1" else {
89 throw XCTSkip("Set TEST_RUNNER_OPEN_ONLY=1 (with a joined install) for the screens pass.")
90 }
91 let app = XCUIApplication()
92 app.launch()
93 let row = app.staticTexts["Paula"].firstMatch
94 XCTAssertTrue(row.waitForExistence(timeout: 15), "no Paula chat on this install")
95
96 // Edge-drag pulls the server drawer in under the finger; drag it back shut.
97 let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
98 let middle = app.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: 0.5))
99 edge.press(forDuration: 0.05, thenDragTo: middle, withVelocity: .slow, thenHoldForDuration: 0.1)
100 let serversTitle = app.staticTexts["server-drawer-title"].firstMatch
101 XCTAssertTrue(serversTitle.waitForExistence(timeout: 3) && serversTitle.isHittable,
102 "drawer didn't open from edge drag")
103 shoot(app, "15-drawer-dragged")
104 // Close by dragging from the scrim (the drawer panel's scroll view owns
105 // gestures that start inside it; the scrim also closes on a plain tap).
106 let scrim = app.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5))
107 scrim.press(forDuration: 0.2, thenDragTo: edge, withVelocity: .slow, thenHoldForDuration: 0.2)
108 sleep(1)
109 shoot(app, "16-drawer-closed")
110 // Offscreen elements never leave the XCUITest tree — `exists` stays true
111 // forever; hittability is what actually flips when the drawer slides out.
112 let hidden = NSPredicate(format: "isHittable == false")
113 let closed = XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: hidden, object: serversTitle)],
114 timeout: 3) == .completed
115 XCTAssertTrue(closed, "drawer didn't close from drag")
116
117 row.tap()
118 let composer = app.textViews.firstMatch
119 XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer")
120 sleep(2) // let the feed render + settle
121 shoot(app, "10-chat-floating")
122
123 // Swipe-right on a message → the native reply banner appears; ✕ clears it.
124 let bubble = webElement(app, "hello back from Paula")
125 if bubble.waitForExistence(timeout: 5) {
126 bubble.swipeRight()
127 let banner = app.staticTexts["Replying to Paula"].firstMatch
128 XCTAssertTrue(banner.waitForExistence(timeout: 5), "reply banner never appeared")
129 shoot(app, "12-reply-banner")
130 app.buttons["Cancel reply"].firstMatch.tap()
131 XCTAssertTrue(waitGone(banner, timeout: 5), "reply banner didn't clear")
132 }
133
134 composer.tap()
135 sleep(2) // keyboard animation
136 shoot(app, "11-chat-keyboard")
137
138 // :emoji typeahead — the strip appears with ranked glyphs; a tap splices.
139 composer.typeText(":tada")
140 let emojiChip = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", ":tada:")).firstMatch
141 XCTAssertTrue(emojiChip.waitForExistence(timeout: 5), "emoji typeahead never appeared")
142 shoot(app, "13-emoji-typeahead")
143 emojiChip.tap()
144 XCTAssertTrue(((composer.value as? String) ?? "").contains("🎉"), "emoji wasn't spliced in")
145
146 // @mention typeahead over the roster.
147 composer.typeText(" @Pa")
148 let mentionChip = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Paula")).firstMatch
149 XCTAssertTrue(mentionChip.waitForExistence(timeout: 5), "mention typeahead never appeared")
150 shoot(app, "14-mention-typeahead")
151 mentionChip.tap()
152 XCTAssertTrue(((composer.value as? String) ?? "").contains("@Paula"), "mention wasn't spliced in")
153 }
154
155 private func waitGone(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
156 let p = NSPredicate(format: "exists == false")
157 return XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: p, object: element)],
158 timeout: timeout) == .completed
159 }
160
161 // Web content exposes elements inconsistently (button vs link vs static text
162 // depending on markup) — match any descendant by label.
163 private func webElement(_ app: XCUIApplication, _ label: String) -> XCUIElement {
164 app.webViews.descendants(matching: .any).matching(
165 NSPredicate(format: "label CONTAINS %@", label)
166 ).firstMatch
167 }
168
169 private func shoot(_ app: XCUIApplication, _ name: String) {
170 let shot = XCTAttachment(screenshot: app.screenshot())
171 shot.name = name
172 shot.lifetime = .keepAlways
173 add(shot)
174 }
175}
176