oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import UIKit
2import XCTest
3
4final class CallsPhoneUITests: XCTestCase {
5 @MainActor func testSignedOutHomeIsSignInAndGuest() {
6 let app = XCUIApplication()
7 app.launchArguments += ["-CallsForgetSignIn", "YES"]
8 app.launch()
9 XCTAssertTrue(app.buttons["signIn"].waitForExistence(timeout: 15))
10 XCTAssertTrue(app.buttons["joinAsGuest"].exists)
11 XCTAssertFalse(app.textFields["meetingLink"].exists, "nothing to type to sign in")
12 XCTAssertTrue(
13 app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "calls.oficina.works"))
14 .firstMatch.exists)
15 app.buttons["settings"].tap()
16 XCTAssertTrue(app.textFields["serverAddress"].waitForExistence(timeout: 5))
17 XCTAssertEqual(app.textFields["serverAddress"].value as? String, "https://calls.oficina.works")
18 }
19
20 @MainActor func testInvalidGuestLink() {
21 let app = XCUIApplication()
22 app.launchArguments += ["-CallsForgetSignIn", "YES"]
23 app.launch()
24 let guest = app.buttons["joinAsGuest"]
25 XCTAssertTrue(guest.waitForExistence(timeout: 15))
26 guest.tap()
27 let input = app.textFields["inviteLink"]
28 XCTAssertTrue(input.waitForExistence(timeout: 5))
29 input.tap()
30 input.typeText("not a meeting")
31 app.buttons["guestJoin"].tap()
32 XCTAssertTrue(
33 app.staticTexts.containing(
34 NSPredicate(format: "label CONTAINS[c] %@", "Paste the whole invite link")
35 ).firstMatch.waitForExistence(timeout: 10))
36 XCTAssertTrue(app.textFields["inviteLink"].exists, "the sheet stays for another try")
37 }
38
39 @MainActor func testEmailBrowserHandoff() async throws {
40 let env = ProcessInfo.processInfo.environment
41 guard let meeting = env["CALLS_TEST_MEETING"], let control = env["CALLS_TEST_CONTROL"],
42 let address = env["CALLS_TEST_EMAIL"], let origin = env["CALLS_TEST_ORIGIN"]
43 else {
44 throw XCTSkip("Run the Go iPhone browser harness for a temporary server and captured email.")
45 }
46 // A guest pastes the invite into the guest screen; a member signs in
47 // to the server, which the harness sets for this run only.
48 let guest = env["CALLS_TEST_GUEST"] == "1"
49 let app = XCUIApplication()
50 app.launchArguments += ["-CallsForgetSignIn", "YES", "-CallsServer", origin]
51 app.launch()
52 if guest {
53 let button = app.buttons["joinAsGuest"]
54 XCTAssertTrue(button.waitForExistence(timeout: 15))
55 button.tap()
56 let input = app.textFields["inviteLink"]
57 XCTAssertTrue(input.waitForExistence(timeout: 5))
58 input.tap()
59 input.typeText(meeting)
60 app.buttons["guestJoin"].tap()
61 } else {
62 let button = app.buttons["signIn"]
63 XCTAssertTrue(button.waitForExistence(timeout: 15))
64 button.tap()
65 }
66 let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
67 let email = safari.textFields["Email address"]
68 // An invitation lands on the web's guest screen, whose "sign in" link
69 // leads to the address field, as test/browser/native-auth.mjs follows it.
70 if !email.waitForExistence(timeout: 10) {
71 if safari.buttons["Continue"].exists {
72 safari.buttons["Continue"].tap()
73 } else if safari.links["sign in"].exists {
74 safari.links["sign in"].tap()
75 }
76 }
77 XCTAssertTrue(email.waitForExistence(timeout: 20))
78 email.tap()
79 // Submitted with Return: Safari's autofill bubble can sit over the
80 // button while the keyboard is up and take the tap instead.
81 email.typeText(address + "\n")
82 var link: URL?
83 // Up to fifteen seconds: signing in to the server itself answers more
84 // slowly than a meeting link (see TestNativeBrowserAuthentication/server).
85 for _ in 0..<60 {
86 let (data, _) = try await URLSession.shared.data(from: URL(string: control)!)
87 if let response = try JSONSerialization.jsonObject(with: data) as? [String: String],
88 let text = response["link"], !text.isEmpty
89 {
90 link = URL(string: text)
91 break
92 }
93 try await Task.sleep(for: .milliseconds(250))
94 }
95 let verification = try XCTUnwrap(link)
96 XCUIDevice.shared.system.open(verification)
97 let approve = safari.buttons["Sign in to Calls app"]
98 XCTAssertTrue(approve.waitForExistence(timeout: 20))
99 app.activate()
100 let code = app.staticTexts.matching(
101 NSPredicate(format: "label MATCHES %@", "[A-F0-9]{4}-[A-F0-9]{4}")
102 ).firstMatch
103 XCTAssertTrue(code.waitForExistence(timeout: 10))
104 let expected = code.label
105 safari.activate()
106 XCTAssertTrue(
107 safari.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", expected)).firstMatch
108 .exists)
109 approve.tap()
110 app.activate()
111 if guest {
112 XCTAssertTrue(app.buttons["Join meeting"].waitForExistence(timeout: 20))
113 XCTAssertTrue(app.staticTexts["Ready to join"].exists)
114 } else {
115 XCTAssertTrue(app.buttons["startMeeting"].waitForExistence(timeout: 20))
116 XCTAssertTrue(app.staticTexts["Ready when you are"].exists)
117 }
118 let attachment = XCTAttachment(screenshot: app.screenshot())
119 attachment.name = "iPhone signed in through Safari"
120 attachment.lifetime = .keepAlways
121 add(attachment)
122 }
123
124 // MARK: The full-screen call
125
126 /// Launches the debug build's described call (see PhoneCallFixture).
127 @MainActor private func launchCall(
128 _ fixture: String = "focus", _ extra: [String] = [], dark: Bool = false
129 ) -> XCUIApplication {
130 let app = XCUIApplication()
131 app.launchArguments += ["-CallsCallFixture", fixture, "-CallsFixtureDark", dark ? "YES" : "NO"]
132 app.launchArguments += extra
133 app.launch()
134 return app
135 }
136
137 /// Where the snapshots go: CALLS_SNAPSHOT_DIR (TEST_RUNNER_CALLS_SNAPSHOT_DIR
138 /// to xcodebuild), or /tmp/immersive-call. The simulator writes the Mac's
139 /// own /tmp.
140 private var snapshotDirectory: URL {
141 URL(
142 fileURLWithPath: ProcessInfo.processInfo.environment["CALLS_SNAPSHOT_DIR"]
143 ?? "/tmp/immersive-call")
144 }
145
146 /// The screen as the device draws it: upright for portrait, and turned
147 /// a quarter for landscape (straightened afterwards with `sips -r 270`;
148 /// the app's own screenshot crops landscape wrongly on iOS 26).
149 @MainActor private func snapshot(_ app: XCUIApplication, _ name: String) throws {
150 let shot = XCUIScreen.main.screenshot()
151 let attachment = XCTAttachment(screenshot: shot)
152 attachment.name = name
153 attachment.lifetime = .keepAlways
154 add(attachment)
155 try FileManager.default.createDirectory(
156 at: snapshotDirectory, withIntermediateDirectories: true)
157 try shot.pngRepresentation.write(
158 to: snapshotDirectory.appendingPathComponent("iphone-\(name).png"))
159 }
160
161 /// Paul's layout: the menu on the left, End call on the right, fading
162 /// 3 s after the last touch, back with a tap, and every other control
163 /// in the menu.
164 @MainActor func testFullScreenCallControls() throws {
165 XCUIDevice.shared.orientation = .portrait
166 let app = launchCall()
167 let more = app.buttons["moreControls"]
168 let end = app.buttons["leave"]
169 XCTAssertTrue(more.waitForExistence(timeout: 15))
170 XCTAssertTrue(end.exists)
171 XCTAssertLessThan(more.frame.midX, end.frame.midX, "the menu is left of End call")
172 XCTAssertFalse(app.buttons["mute"].exists, "Mute is in the menu, not the bar")
173
174 // They go on their own, and a tap on the picture brings them back.
175 let gone = NSPredicate(format: "exists == false")
176 expectation(for: gone, evaluatedWith: more)
177 waitForExpectations(timeout: 6)
178 app.descendants(matching: .any)["callStage"].firstMatch.tap()
179 XCTAssertTrue(more.waitForExistence(timeout: 3))
180 // A second tap hides them again at once.
181 app.descendants(matching: .any)["callStage"].firstMatch.tap()
182 expectation(for: gone, evaluatedWith: more)
183 waitForExpectations(timeout: 2)
184 app.descendants(matching: .any)["callStage"].firstMatch.tap()
185 XCTAssertTrue(more.waitForExistence(timeout: 3))
186
187 more.tap()
188 let mute = app.buttons["mute"]
189 XCTAssertTrue(mute.waitForExistence(timeout: 5))
190 for control in ["camera", "flipCamera", "devices", "micModeQuick", "videoLayout", "openChat"] {
191 XCTAssertTrue(app.buttons[control].exists, "\(control) is in the menu")
192 }
193 XCTAssertTrue(app.switches["noiseReduction"].exists)
194 XCTAssertTrue(app.descendants(matching: .any)["shareScreen"].exists)
195 XCTAssertTrue(app.buttons["Share meeting link"].exists, "a member can share the link")
196 mute.tap()
197 XCTAssertEqual(mute.label, "Unmute microphone")
198 // The controls stay while the menu is open, however long that is.
199 Thread.sleep(forTimeInterval: 3.5)
200 app.buttons["Done"].tap()
201 XCTAssertTrue(more.waitForExistence(timeout: 3), "still up just after the menu closes")
202 let selfView = app.descendants(matching: .any)["selfView"]
203 XCTAssertTrue(selfView.exists)
204 XCTAssertTrue(selfView.label.contains("muted"), "muted says so on the self view")
205
206 // Chat from the menu opens once the menu has gone.
207 more.tap()
208 XCTAssertTrue(app.buttons["openChat"].waitForExistence(timeout: 5))
209 app.buttons["openChat"].tap()
210 XCTAssertTrue(app.textFields["chatComposer"].waitForExistence(timeout: 5))
211 }
212
213 /// Spec §15.26: "Report a problem" from the call's controls menu opens
214 /// its own sheet — the optional note with its counter, the disclosure in
215 /// the ruling's words, Send and Cancel — and a sent report shows its
216 /// reference. The fixture call answers the report itself.
217 @MainActor func testReportAProblemFromTheCallScreen() throws {
218 XCUIDevice.shared.orientation = .portrait
219 let app = launchCall("focus", ["-CallsFixtureControls", "shown"])
220 let more = app.buttons["moreControls"]
221 XCTAssertTrue(more.waitForExistence(timeout: 15))
222 more.tap()
223 let report = app.buttons["reportProblem"]
224 XCTAssertTrue(report.waitForExistence(timeout: 5), "Report a problem is in the controls menu")
225 if !report.isHittable { app.swipeUp() }
226 report.tap()
227
228 let note = app.textViews["reportProblemNote"]
229 XCTAssertTrue(note.waitForExistence(timeout: 5), "the sheet opens once the menu has gone")
230 XCTAssertTrue(app.navigationBars["Report a problem"].exists)
231 XCTAssertTrue(
232 app.staticTexts["Sends this call's connection diagnostics to the Calls server log. No audio or video."].exists,
233 "the disclosure, word for word")
234 let counter = app.staticTexts["reportProblemCounter"]
235 XCTAssertTrue(counter.exists)
236 XCTAssertTrue(app.buttons["reportProblemCancel"].exists)
237 let send = app.buttons["reportProblemSend"]
238 XCTAssertTrue(send.isEnabled, "the note is optional")
239 note.tap()
240 note.typeText("Echo")
241 XCTAssertEqual(counter.label, "4 of 1000 characters", "the counter follows the note")
242 try snapshot(app, "report-problem")
243 send.tap()
244
245 let reference = app.staticTexts["reportProblemReference"]
246 XCTAssertTrue(reference.waitForExistence(timeout: 5))
247 XCTAssertEqual(reference.label, "Reference K7Q2M9PD")
248 XCTAssertTrue(app.staticTexts["Sent — thank you"].exists)
249 XCTAssertTrue(app.buttons["reportProblemCopy"].exists)
250 try snapshot(app, "report-problem-sent")
251 app.buttons["reportProblemDone"].tap()
252 XCTAssertFalse(reference.waitForExistence(timeout: 2), "Done puts it away")
253 }
254
255 /// No title pill (2026-10-02): a meeting someone named shows its title,
256 /// small and centred at the top, and nothing about how the call is going
257 /// beside it; a meeting Calls named ("Call, 24 Sep 10:21") shows none.
258 @MainActor func testTheTitleShowsOnlyForANamedMeeting() throws {
259 XCUIDevice.shared.orientation = .portrait
260 let named = launchCall("named", ["-CallsFixtureControls", "shown"])
261 let title = named.staticTexts["callTitle"]
262 XCTAssertTrue(title.waitForExistence(timeout: 15))
263 XCTAssertEqual(title.label, "Board meeting")
264 XCTAssertEqual(title.frame.midX, named.frame.midX, accuracy: 2, "centred")
265 XCTAssertGreaterThan(title.frame.minY, 44, "below the status bar: inside the safe area")
266 XCTAssertFalse(named.staticTexts["In the meeting"].exists, "no pill saying how the call is going")
267 try snapshot(named, "named-controls-portrait-light")
268 let auto = launchCall("focus", ["-CallsFixtureControls", "shown"])
269 XCTAssertTrue(auto.buttons["leave"].waitForExistence(timeout: 15))
270 XCTAssertFalse(auto.staticTexts["callTitle"].exists, "an automatic title is not shown")
271 XCTAssertFalse(auto.staticTexts["Call, 24 Sep 10:21"].exists)
272 XCTAssertFalse(auto.staticTexts["In the meeting"].exists)
273 try snapshot(auto, "auto-controls-portrait-light")
274 }
275
276 /// The snapshots of the full-screen call and the classic one, light and
277 /// dark, portrait and landscape.
278 @MainActor func testFullScreenCallSnapshots() throws {
279 defer { XCUIDevice.shared.orientation = .portrait }
280 for dark in [false, true] {
281 let scheme = dark ? "dark" : "light"
282 for orientation in [UIDeviceOrientation.portrait, .landscapeLeft] {
283 XCUIDevice.shared.orientation = orientation
284 let turn = orientation == .portrait ? "portrait" : "landscape"
285 for (fixture, shown) in [
286 ("focus", true), ("focus", false), ("grid", true), ("screen", false), ("named", true),
287 ("named", false),
288 ] {
289 let app = launchCall(
290 fixture, ["-CallsFixtureControls", shown ? "shown" : "hidden"], dark: dark)
291 XCTAssertTrue(app.descendants(matching: .any)["callStage"].firstMatch.waitForExistence(timeout: 15))
292 Thread.sleep(forTimeInterval: 1)
293 try snapshot(app, "\(fixture)-\(shown ? "controls" : "hidden")-\(turn)-\(scheme)")
294 }
295 let menu = launchCall(
296 "focus", ["-CallsFixtureControls", "shown", "-CallsFixtureMore", "YES"], dark: dark)
297 XCTAssertTrue(menu.buttons["mute"].waitForExistence(timeout: 15))
298 Thread.sleep(forTimeInterval: 1)
299 try snapshot(menu, "menu-\(turn)-\(scheme)")
300 }
301 XCUIDevice.shared.orientation = .portrait
302 let notices = launchCall("notices", ["-CallsFixtureControls", "hidden"], dark: dark)
303 XCTAssertTrue(notices.buttons["Let in"].waitForExistence(timeout: 15))
304 Thread.sleep(forTimeInterval: 1)
305 try snapshot(notices, "notices-hidden-portrait-\(scheme)")
306 let classic = launchCall("focus", ["-CallsFixtureClassic", "YES"], dark: dark)
307 XCTAssertTrue(classic.buttons["leave"].waitForExistence(timeout: 15))
308 Thread.sleep(forTimeInterval: 1)
309 try snapshot(classic, "classic-portrait-\(scheme)")
310 }
311 }
312
313 /// Spec §15.24: two people who have just joined — one connecting and
314 /// unable to hear you yet, the other's browser blocking sound in place of
315 /// that — in Grid and Speaker, light and dark. The notice is on the tile,
316 /// and VoiceOver hears it in the tile's label, without the dots.
317 @MainActor func testConnectingSnapshots() throws {
318 XCUIDevice.shared.orientation = .portrait
319 for dark in [false, true] {
320 let scheme = dark ? "dark" : "light"
321 for layout in ["grid", "speaker"] {
322 let app = launchCall(
323 "connecting",
324 ["-CallsFixtureControls", "hidden", "-CallsFixtureLayout", layout, "-CallsFixtureOrder", "sam,bea,ana,me"],
325 dark: dark)
326 let notice = app.descendants(matching: .any)
327 .matching(NSPredicate(format: "label CONTAINS %@", "Samuel Okafor is connecting and can’t hear you yet"))
328 .firstMatch
329 XCTAssertTrue(notice.waitForExistence(timeout: 15), "Samuel's tile says it")
330 XCTAssertFalse(notice.label.contains("…"), "and VoiceOver hears no dots")
331 Thread.sleep(forTimeInterval: 1)
332 try snapshot(app, "connecting-\(layout)-portrait-\(scheme)")
333 }
334 }
335 }
336
337 /// The overlay over black, white and a busy frame, in each of your own
338 /// states, light and dark: it should look the same in both. The
339 /// microphone sits beside the menu here so its muted state shows.
340 @MainActor func testOverlayContrastSnapshots() throws {
341 XCUIDevice.shared.orientation = .portrait
342 for dark in [false, true] {
343 for backdrop in ["black", "white", "busy"] {
344 for me in ["normal", "muted", "cameraOff", "sharing"] {
345 let app = launchCall(
346 "focus",
347 [
348 "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop,
349 "-CallsFixtureSelf", me, "-CallsMicBesideMenu", "YES",
350 ], dark: dark)
351 XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15))
352 XCTAssertTrue(app.buttons["mute"].exists, "beside the menu")
353 if me == "muted" { XCTAssertEqual(app.buttons["mute"].label, "Unmute microphone") }
354 Thread.sleep(forTimeInterval: 1)
355 try snapshot(app, "contrast-\(backdrop)-\(me)-\(dark ? "dark" : "light")")
356 }
357 }
358 let notices = launchCall(
359 "notices", ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "white"], dark: dark)
360 XCTAssertTrue(notices.buttons["Let in"].waitForExistence(timeout: 15))
361 Thread.sleep(forTimeInterval: 1)
362 try snapshot(notices, "contrast-white-notices-\(dark ? "dark" : "light")")
363 }
364 }
365
366 /// 0.4.3: the warm rim on a name at four levels, over the rim mock-up's
367 /// dark and bright tiles, white, and the busy frame (`WarmRimGallery`).
368 @MainActor func testWarmRimSnapshots() throws {
369 XCUIDevice.shared.orientation = .portrait
370 for level in ["silent", "low", "mid", "high"] {
371 let app = XCUIApplication()
372 app.launchArguments += ["-CallsFixtureRimGallery", level]
373 app.launch()
374 XCTAssertTrue(app.staticTexts["Vicky Walsh"].firstMatch.waitForExistence(timeout: 15))
375 Thread.sleep(forTimeInterval: 1)
376 try snapshot(app, "rim-\(level)")
377 }
378 }
379
380 /// 0.3.7: the names glowing at four levels, in the grid and in focus,
381 /// and the glass over black, white and a busy frame, in Liquid Glass and
382 /// in the material older systems draw instead.
383 @MainActor func testGlowAndGlassSnapshots() throws {
384 XCUIDevice.shared.orientation = .portrait
385 for level in ["silent", "low", "mid", "high"] {
386 for fixture in ["grid", "focus"] {
387 let app = launchCall(
388 fixture, ["-CallsFixtureControls", "shown", "-CallsFixtureSpeaking", level], dark: true)
389 XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15))
390 Thread.sleep(forTimeInterval: 1)
391 try snapshot(app, "glow-\(fixture)-\(level)")
392 }
393 }
394 for glass in [true, false] {
395 for backdrop in ["black", "white", "busy"] {
396 let app = launchCall(
397 "notices",
398 [
399 "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop,
400 "-CallsMicBesideMenu", "YES", "-CallsFixtureNoGlass", glass ? "NO" : "YES",
401 ], dark: true)
402 XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15))
403 Thread.sleep(forTimeInterval: 1)
404 try snapshot(app, "bar-\(glass ? "glass" : "fallback")-\(backdrop)")
405 }
406 }
407 }
408
409 /// 0.4.0: Speaker view in a call of four. Tom has the floor and the
410 /// other three, you included, are in the filmstrip; a tap on Li pins Li
411 /// to the stage with a Pinned badge, and the badge unpins. The layout is
412 /// in the ⋯ sheet as Speaker and Grid.
413 @MainActor func testSpeakerViewPinsWithATap() throws {
414 XCUIDevice.shared.orientation = .portrait
415 let app = launchCall(
416 "grid",
417 ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "shown"])
418 let filmstrip = app.descendants(matching: .any)["filmstrip"]
419 XCTAssertTrue(filmstrip.waitForExistence(timeout: 15))
420 let stage = app.descendants(matching: .any).matching(
421 NSPredicate(format: "label BEGINSWITH %@", "On stage: ")).firstMatch
422 XCTAssertTrue(stage.waitForExistence(timeout: 5))
423 XCTAssertTrue(stage.label.hasPrefix("On stage: Tom Byrne"), stage.label)
424 for name in ["You", "Ana García", "Li Wei"] {
425 XCTAssertTrue(
426 filmstrip.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", name))
427 .firstMatch.exists, "\(name) is in the filmstrip")
428 }
429 XCTAssertFalse(app.descendants(matching: .any)["selfView"].exists, "you are in the filmstrip, not floating")
430 filmstrip.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "Li Wei"))
431 .firstMatch.tap()
432 let unpin = app.buttons["unpin"]
433 XCTAssertTrue(unpin.waitForExistence(timeout: 3), "a pinned stage shows its pin")
434 XCTAssertTrue(stage.label.hasPrefix("On stage: Li Wei"), stage.label)
435 XCTAssertTrue(stage.label.hasSuffix("pinned"), stage.label)
436 unpin.tap()
437 XCTAssertTrue(
438 app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "On stage: Tom Byrne"))
439 .firstMatch.waitForExistence(timeout: 3), "unpinned, the speaker has the stage again")
440 // The layout, in the controls sheet.
441 app.buttons["moreControls"].tap()
442 XCTAssertTrue(app.buttons["videoLayout"].waitForExistence(timeout: 5))
443 app.buttons["videoLayout"].tap()
444 XCTAssertTrue(app.buttons["Speaker"].waitForExistence(timeout: 3))
445 XCTAssertTrue(app.buttons["Grid"].exists)
446 XCTAssertFalse(app.buttons["Focus"].exists, "Focus is now Speaker")
447 }
448
449 /// A tile's accessibility actions move it without a drag: Move to first
450 /// on Li in Grid puts Li before you.
451 @MainActor func testTilesMoveWithoutADrag() throws {
452 XCUIDevice.shared.orientation = .portrait
453 let app = launchCall("grid", ["-CallsFixtureControls", "hidden"])
454 let li = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "Li Wei")).firstMatch
455 let me = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "You")).firstMatch
456 XCTAssertTrue(li.waitForExistence(timeout: 15))
457 XCTAssertGreaterThan(li.frame.minY + li.frame.minX / 1000, me.frame.minY + me.frame.minX / 1000, "Li starts after you")
458 let centre = CGVector(dx: 0.5, dy: 0.5)
459 li.coordinate(withNormalizedOffset: centre).press(
460 forDuration: 0.8, thenDragTo: me.coordinate(withNormalizedOffset: centre), withVelocity: .slow,
461 thenHoldForDuration: 0.5)
462 Thread.sleep(forTimeInterval: 1)
463 XCTAssertLessThanOrEqual(
464 li.frame.minY * 1000 + li.frame.minX, me.frame.minY * 1000 + me.frame.minX,
465 "a long press and drag onto your place puts Li there")
466 }
467
468 /// Snapshots of Speaker view and the order (0.4.0), portrait and
469 /// landscape: the filmstrip of three, a pin, a drag half way, and Grid
470 /// after a reorder.
471 @MainActor func testSpeakerViewSnapshots() throws {
472 defer { XCUIDevice.shared.orientation = .portrait }
473 for orientation in [UIDeviceOrientation.portrait, .landscapeLeft] {
474 XCUIDevice.shared.orientation = orientation
475 let turn = orientation == .portrait ? "portrait" : "landscape"
476 let cases: [(String, [String])] = [
477 ("speaker-filmstrip", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "hidden"]),
478 ("speaker-controls", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "shown"]),
479 ("speaker-pinned", ["-CallsFixtureLayout", "speaker", "-CallsFixturePinned", "li", "-CallsFixtureControls", "hidden"]),
480 ("speaker-drag", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureLift", "li", "-CallsFixtureControls", "hidden"]),
481 ("grid-drag", ["-CallsFixtureLayout", "grid", "-CallsFixtureLift", "li", "-CallsFixtureControls", "hidden"]),
482 ("grid-reordered", ["-CallsFixtureLayout", "grid", "-CallsFixtureOrder", "li,tom,me,ana", "-CallsFixtureControls", "hidden"]),
483 ]
484 for (name, extra) in cases {
485 let app = launchCall("grid", extra, dark: true)
486 XCTAssertTrue(app.descendants(matching: .any)["callStage"].firstMatch.waitForExistence(timeout: 15))
487 Thread.sleep(forTimeInterval: 1)
488 try snapshot(app, "\(name)-\(turn)")
489 }
490 }
491 }
492
493 /// Landscape keeps both bar buttons on screen, clear of the Dynamic
494 /// Island's side.
495 @MainActor func testLandscapeKeepsTheBarOnScreen() throws {
496 XCUIDevice.shared.orientation = .landscapeLeft
497 defer { XCUIDevice.shared.orientation = .portrait }
498 let app = launchCall("focus", ["-CallsFixtureControls", "shown"])
499 XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15))
500 XCTAssertTrue(app.buttons["leave"].isHittable)
501 XCTAssertTrue(app.buttons["moreControls"].isHittable)
502 XCTAssertLessThan(app.buttons["moreControls"].frame.midX, app.buttons["leave"].frame.midX)
503 }
504
505 // MARK: Glyphs (0.4.1)
506
507 /// Paul, on the 0.4.0 bar: "Are these vertically aligned?" Every
508 /// symbol a call control draws goes through CallGlyph and its optical
509 /// table; the Mac's tests measure it drawn by AppKit, and this measures
510 /// it as the iPhone draws it: the round bar buttons, the microphone
511 /// beside the menu, and every button in the controls sheet, each
512 /// symbol's ink within half a point of its button's centre line.
513 @MainActor func testControlGlyphsAreOnTheirCentreLines() throws {
514 XCUIDevice.shared.orientation = .portrait
515 var report: [String] = []
516 var failures: [String] = []
517 func measure(_ app: XCUIApplication, _ ids: [String], _ label: String) {
518 let shot = XCUIScreen.main.screenshot()
519 for id in ids {
520 let button = app.buttons[id].firstMatch
521 guard button.exists else {
522 failures.append("\(label): \(id) is not there")
523 continue
524 }
525 guard let offset = GlyphInk.offset(in: shot.image, frame: button.frame) else {
526 failures.append("\(label): \(id) drew no symbol")
527 continue
528 }
529 report.append(String(format: "%@ %@: %+.2f pt", label, id, offset))
530 if abs(offset) > 0.5 { failures.append(String(format: "%@ %@: %+.2f pt", label, id, offset)) }
531 }
532 }
533 let bar = launchCall(
534 "focus",
535 ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "black", "-CallsMicBesideMenu", "YES"],
536 dark: true)
537 XCTAssertTrue(bar.buttons["moreControls"].waitForExistence(timeout: 15))
538 Thread.sleep(forTimeInterval: 1)
539 measure(bar, ["moreControls", "mute", "leave"], "bar")
540 bar.terminate()
541 for dark in [false, true] {
542 let sheet = launchCall("focus", ["-CallsFixtureControls", "shown", "-CallsFixtureMore", "YES"], dark: dark)
543 XCTAssertTrue(sheet.buttons["devices"].waitForExistence(timeout: 15))
544 Thread.sleep(forTimeInterval: 1.5)
545 measure(
546 sheet, ["mute", "camera", "flipCamera", "devices", "micModeQuick", "videoLayout", "Share meeting link", "openChat"],
547 dark ? "sheet dark" : "sheet light")
548 sheet.terminate()
549 }
550 let attachment = XCTAttachment(string: report.joined(separator: "\n"))
551 attachment.name = "glyph centroids"
552 attachment.lifetime = .keepAlways
553 add(attachment)
554 try FileManager.default.createDirectory(at: snapshotDirectory, withIntermediateDirectories: true)
555 try report.joined(separator: "\n").write(
556 to: snapshotDirectory.appendingPathComponent("iphone-glyph-centroids.txt"), atomically: true,
557 encoding: .utf8)
558 XCTAssert(failures.isEmpty, failures.joined(separator: "\n") + "\n" + report.joined(separator: "\n"))
559 }
560}
561
562/// Where a button's symbol's ink is: the pixels of its frame weighted by
563/// how far their colour is from the button's own background, sampled
564/// just inside its left edge at mid-height (a round button's glass, a
565/// tinted square's fill, a red capsule), as the tests on the Mac do.
566enum GlyphInk {
567 static func offset(in image: UIImage, frame: CGRect) -> CGFloat? {
568 guard let cg = image.cgImage else { return nil }
569 let scale = CGFloat(cg.width) / image.size.width
570 let rect = CGRect(
571 x: (frame.minX * scale).rounded(), y: (frame.minY * scale).rounded(),
572 width: (frame.width * scale).rounded(), height: (frame.height * scale).rounded())
573 guard let crop = cg.cropping(to: rect) else { return nil }
574 let w = crop.width, h = crop.height
575 var px = [UInt8](repeating: 0, count: w * h * 4)
576 guard
577 let ctx = CGContext(
578 data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
579 space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
580 else { return nil }
581 ctx.draw(crop, in: CGRect(x: 0, y: 0, width: w, height: h))
582 // The background: the median of a short column just inside the
583 // left edge, clear of the symbol and of a rounded corner.
584 let x = max(2, Int(4 * scale))
585 var samples: [(Int, Int, Int)] = []
586 for y in (h / 2 - Int(3 * scale))...(h / 2 + Int(3 * scale)) {
587 let i = (y * w + x) * 4
588 samples.append((Int(px[i]), Int(px[i + 1]), Int(px[i + 2])))
589 }
590 samples.sort { $0.0 + $0.1 + $0.2 < $1.0 + $1.1 + $1.2 }
591 let bg = samples[samples.count / 2]
592 var total = 0.0, moment = 0.0
593 // Only the middle of the button, where the symbol is: a square's
594 // rounded corners and a circle's edge stay out of it.
595 let inset = Int(8 * scale)
596 for y in inset..<(h - inset) {
597 for x in inset..<(w - inset) {
598 let i = (y * w + x) * 4
599 let d = abs(Int(px[i]) - bg.0) + abs(Int(px[i + 1]) - bg.1) + abs(Int(px[i + 2]) - bg.2)
600 let weight = Double(max(0, d - 60))
601 total += weight
602 moment += weight * (Double(y) + 0.5)
603 }
604 }
605 guard total > 0 else { return nil }
606 return CGFloat(moment / total) / scale - frame.height / 2 + (rect.minY / scale - frame.minY)
607 }
608}
609