| 1 | import UIKit |
| 2 | import XCTest |
| 3 | |
| 4 | final 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. |
| 566 | enum 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 | |