import UIKit import XCTest final class CallsPhoneUITests: XCTestCase { @MainActor func testSignedOutHomeIsSignInAndGuest() { let app = XCUIApplication() app.launchArguments += ["-CallsForgetSignIn", "YES"] app.launch() XCTAssertTrue(app.buttons["signIn"].waitForExistence(timeout: 15)) XCTAssertTrue(app.buttons["joinAsGuest"].exists) XCTAssertFalse(app.textFields["meetingLink"].exists, "nothing to type to sign in") XCTAssertTrue( app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "calls.oficina.works")) .firstMatch.exists) app.buttons["settings"].tap() XCTAssertTrue(app.textFields["serverAddress"].waitForExistence(timeout: 5)) XCTAssertEqual(app.textFields["serverAddress"].value as? String, "https://calls.oficina.works") } @MainActor func testInvalidGuestLink() { let app = XCUIApplication() app.launchArguments += ["-CallsForgetSignIn", "YES"] app.launch() let guest = app.buttons["joinAsGuest"] XCTAssertTrue(guest.waitForExistence(timeout: 15)) guest.tap() let input = app.textFields["inviteLink"] XCTAssertTrue(input.waitForExistence(timeout: 5)) input.tap() input.typeText("not a meeting") app.buttons["guestJoin"].tap() XCTAssertTrue( app.staticTexts.containing( NSPredicate(format: "label CONTAINS[c] %@", "Paste the whole invite link") ).firstMatch.waitForExistence(timeout: 10)) XCTAssertTrue(app.textFields["inviteLink"].exists, "the sheet stays for another try") } @MainActor func testEmailBrowserHandoff() async throws { let env = ProcessInfo.processInfo.environment guard let meeting = env["CALLS_TEST_MEETING"], let control = env["CALLS_TEST_CONTROL"], let address = env["CALLS_TEST_EMAIL"], let origin = env["CALLS_TEST_ORIGIN"] else { throw XCTSkip("Run the Go iPhone browser harness for a temporary server and captured email.") } // A guest pastes the invite into the guest screen; a member signs in // to the server, which the harness sets for this run only. let guest = env["CALLS_TEST_GUEST"] == "1" let app = XCUIApplication() app.launchArguments += ["-CallsForgetSignIn", "YES", "-CallsServer", origin] app.launch() if guest { let button = app.buttons["joinAsGuest"] XCTAssertTrue(button.waitForExistence(timeout: 15)) button.tap() let input = app.textFields["inviteLink"] XCTAssertTrue(input.waitForExistence(timeout: 5)) input.tap() input.typeText(meeting) app.buttons["guestJoin"].tap() } else { let button = app.buttons["signIn"] XCTAssertTrue(button.waitForExistence(timeout: 15)) button.tap() } let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") let email = safari.textFields["Email address"] // An invitation lands on the web's guest screen, whose "sign in" link // leads to the address field, as test/browser/native-auth.mjs follows it. if !email.waitForExistence(timeout: 10) { if safari.buttons["Continue"].exists { safari.buttons["Continue"].tap() } else if safari.links["sign in"].exists { safari.links["sign in"].tap() } } XCTAssertTrue(email.waitForExistence(timeout: 20)) email.tap() // Submitted with Return: Safari's autofill bubble can sit over the // button while the keyboard is up and take the tap instead. email.typeText(address + "\n") var link: URL? // Up to fifteen seconds: signing in to the server itself answers more // slowly than a meeting link (see TestNativeBrowserAuthentication/server). for _ in 0..<60 { let (data, _) = try await URLSession.shared.data(from: URL(string: control)!) if let response = try JSONSerialization.jsonObject(with: data) as? [String: String], let text = response["link"], !text.isEmpty { link = URL(string: text) break } try await Task.sleep(for: .milliseconds(250)) } let verification = try XCTUnwrap(link) XCUIDevice.shared.system.open(verification) let approve = safari.buttons["Sign in to Calls app"] XCTAssertTrue(approve.waitForExistence(timeout: 20)) app.activate() let code = app.staticTexts.matching( NSPredicate(format: "label MATCHES %@", "[A-F0-9]{4}-[A-F0-9]{4}") ).firstMatch XCTAssertTrue(code.waitForExistence(timeout: 10)) let expected = code.label safari.activate() XCTAssertTrue( safari.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", expected)).firstMatch .exists) approve.tap() app.activate() if guest { XCTAssertTrue(app.buttons["Join meeting"].waitForExistence(timeout: 20)) XCTAssertTrue(app.staticTexts["Ready to join"].exists) } else { XCTAssertTrue(app.buttons["startMeeting"].waitForExistence(timeout: 20)) XCTAssertTrue(app.staticTexts["Ready when you are"].exists) } let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = "iPhone signed in through Safari" attachment.lifetime = .keepAlways add(attachment) } // MARK: The full-screen call /// Launches the debug build's described call (see PhoneCallFixture). @MainActor private func launchCall( _ fixture: String = "focus", _ extra: [String] = [], dark: Bool = false ) -> XCUIApplication { let app = XCUIApplication() app.launchArguments += ["-CallsCallFixture", fixture, "-CallsFixtureDark", dark ? "YES" : "NO"] app.launchArguments += extra app.launch() return app } /// Where the snapshots go: CALLS_SNAPSHOT_DIR (TEST_RUNNER_CALLS_SNAPSHOT_DIR /// to xcodebuild), or /tmp/immersive-call. The simulator writes the Mac's /// own /tmp. private var snapshotDirectory: URL { URL( fileURLWithPath: ProcessInfo.processInfo.environment["CALLS_SNAPSHOT_DIR"] ?? "/tmp/immersive-call") } /// The screen as the device draws it: upright for portrait, and turned /// a quarter for landscape (straightened afterwards with `sips -r 270`; /// the app's own screenshot crops landscape wrongly on iOS 26). @MainActor private func snapshot(_ app: XCUIApplication, _ name: String) throws { let shot = XCUIScreen.main.screenshot() let attachment = XCTAttachment(screenshot: shot) attachment.name = name attachment.lifetime = .keepAlways add(attachment) try FileManager.default.createDirectory( at: snapshotDirectory, withIntermediateDirectories: true) try shot.pngRepresentation.write( to: snapshotDirectory.appendingPathComponent("iphone-\(name).png")) } /// Paul's layout: the menu on the left, End call on the right, fading /// 3 s after the last touch, back with a tap, and every other control /// in the menu. @MainActor func testFullScreenCallControls() throws { XCUIDevice.shared.orientation = .portrait let app = launchCall() let more = app.buttons["moreControls"] let end = app.buttons["leave"] XCTAssertTrue(more.waitForExistence(timeout: 15)) XCTAssertTrue(end.exists) XCTAssertLessThan(more.frame.midX, end.frame.midX, "the menu is left of End call") XCTAssertFalse(app.buttons["mute"].exists, "Mute is in the menu, not the bar") // They go on their own, and a tap on the picture brings them back. let gone = NSPredicate(format: "exists == false") expectation(for: gone, evaluatedWith: more) waitForExpectations(timeout: 6) app.descendants(matching: .any)["callStage"].firstMatch.tap() XCTAssertTrue(more.waitForExistence(timeout: 3)) // A second tap hides them again at once. app.descendants(matching: .any)["callStage"].firstMatch.tap() expectation(for: gone, evaluatedWith: more) waitForExpectations(timeout: 2) app.descendants(matching: .any)["callStage"].firstMatch.tap() XCTAssertTrue(more.waitForExistence(timeout: 3)) more.tap() let mute = app.buttons["mute"] XCTAssertTrue(mute.waitForExistence(timeout: 5)) for control in ["camera", "flipCamera", "devices", "micModeQuick", "videoLayout", "openChat"] { XCTAssertTrue(app.buttons[control].exists, "\(control) is in the menu") } XCTAssertTrue(app.switches["noiseReduction"].exists) XCTAssertTrue(app.descendants(matching: .any)["shareScreen"].exists) XCTAssertTrue(app.buttons["Share meeting link"].exists, "a member can share the link") mute.tap() XCTAssertEqual(mute.label, "Unmute microphone") // The controls stay while the menu is open, however long that is. Thread.sleep(forTimeInterval: 3.5) app.buttons["Done"].tap() XCTAssertTrue(more.waitForExistence(timeout: 3), "still up just after the menu closes") let selfView = app.descendants(matching: .any)["selfView"] XCTAssertTrue(selfView.exists) XCTAssertTrue(selfView.label.contains("muted"), "muted says so on the self view") // Chat from the menu opens once the menu has gone. more.tap() XCTAssertTrue(app.buttons["openChat"].waitForExistence(timeout: 5)) app.buttons["openChat"].tap() XCTAssertTrue(app.textFields["chatComposer"].waitForExistence(timeout: 5)) } /// Spec §15.26: "Report a problem" from the call's controls menu opens /// its own sheet — the optional note with its counter, the disclosure in /// the ruling's words, Send and Cancel — and a sent report shows its /// reference. The fixture call answers the report itself. @MainActor func testReportAProblemFromTheCallScreen() throws { XCUIDevice.shared.orientation = .portrait let app = launchCall("focus", ["-CallsFixtureControls", "shown"]) let more = app.buttons["moreControls"] XCTAssertTrue(more.waitForExistence(timeout: 15)) more.tap() let report = app.buttons["reportProblem"] XCTAssertTrue(report.waitForExistence(timeout: 5), "Report a problem is in the controls menu") if !report.isHittable { app.swipeUp() } report.tap() let note = app.textViews["reportProblemNote"] XCTAssertTrue(note.waitForExistence(timeout: 5), "the sheet opens once the menu has gone") XCTAssertTrue(app.navigationBars["Report a problem"].exists) XCTAssertTrue( app.staticTexts["Sends this call's connection diagnostics to the Calls server log. No audio or video."].exists, "the disclosure, word for word") let counter = app.staticTexts["reportProblemCounter"] XCTAssertTrue(counter.exists) XCTAssertTrue(app.buttons["reportProblemCancel"].exists) let send = app.buttons["reportProblemSend"] XCTAssertTrue(send.isEnabled, "the note is optional") note.tap() note.typeText("Echo") XCTAssertEqual(counter.label, "4 of 1000 characters", "the counter follows the note") try snapshot(app, "report-problem") send.tap() let reference = app.staticTexts["reportProblemReference"] XCTAssertTrue(reference.waitForExistence(timeout: 5)) XCTAssertEqual(reference.label, "Reference K7Q2M9PD") XCTAssertTrue(app.staticTexts["Sent — thank you"].exists) XCTAssertTrue(app.buttons["reportProblemCopy"].exists) try snapshot(app, "report-problem-sent") app.buttons["reportProblemDone"].tap() XCTAssertFalse(reference.waitForExistence(timeout: 2), "Done puts it away") } /// No title pill (2026-10-02): a meeting someone named shows its title, /// small and centred at the top, and nothing about how the call is going /// beside it; a meeting Calls named ("Call, 24 Sep 10:21") shows none. @MainActor func testTheTitleShowsOnlyForANamedMeeting() throws { XCUIDevice.shared.orientation = .portrait let named = launchCall("named", ["-CallsFixtureControls", "shown"]) let title = named.staticTexts["callTitle"] XCTAssertTrue(title.waitForExistence(timeout: 15)) XCTAssertEqual(title.label, "Board meeting") XCTAssertEqual(title.frame.midX, named.frame.midX, accuracy: 2, "centred") XCTAssertGreaterThan(title.frame.minY, 44, "below the status bar: inside the safe area") XCTAssertFalse(named.staticTexts["In the meeting"].exists, "no pill saying how the call is going") try snapshot(named, "named-controls-portrait-light") let auto = launchCall("focus", ["-CallsFixtureControls", "shown"]) XCTAssertTrue(auto.buttons["leave"].waitForExistence(timeout: 15)) XCTAssertFalse(auto.staticTexts["callTitle"].exists, "an automatic title is not shown") XCTAssertFalse(auto.staticTexts["Call, 24 Sep 10:21"].exists) XCTAssertFalse(auto.staticTexts["In the meeting"].exists) try snapshot(auto, "auto-controls-portrait-light") } /// The snapshots of the full-screen call and the classic one, light and /// dark, portrait and landscape. @MainActor func testFullScreenCallSnapshots() throws { defer { XCUIDevice.shared.orientation = .portrait } for dark in [false, true] { let scheme = dark ? "dark" : "light" for orientation in [UIDeviceOrientation.portrait, .landscapeLeft] { XCUIDevice.shared.orientation = orientation let turn = orientation == .portrait ? "portrait" : "landscape" for (fixture, shown) in [ ("focus", true), ("focus", false), ("grid", true), ("screen", false), ("named", true), ("named", false), ] { let app = launchCall( fixture, ["-CallsFixtureControls", shown ? "shown" : "hidden"], dark: dark) XCTAssertTrue(app.descendants(matching: .any)["callStage"].firstMatch.waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(app, "\(fixture)-\(shown ? "controls" : "hidden")-\(turn)-\(scheme)") } let menu = launchCall( "focus", ["-CallsFixtureControls", "shown", "-CallsFixtureMore", "YES"], dark: dark) XCTAssertTrue(menu.buttons["mute"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(menu, "menu-\(turn)-\(scheme)") } XCUIDevice.shared.orientation = .portrait let notices = launchCall("notices", ["-CallsFixtureControls", "hidden"], dark: dark) XCTAssertTrue(notices.buttons["Let in"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(notices, "notices-hidden-portrait-\(scheme)") let classic = launchCall("focus", ["-CallsFixtureClassic", "YES"], dark: dark) XCTAssertTrue(classic.buttons["leave"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(classic, "classic-portrait-\(scheme)") } } /// Spec §15.24: two people who have just joined — one connecting and /// unable to hear you yet, the other's browser blocking sound in place of /// that — in Grid and Speaker, light and dark. The notice is on the tile, /// and VoiceOver hears it in the tile's label, without the dots. @MainActor func testConnectingSnapshots() throws { XCUIDevice.shared.orientation = .portrait for dark in [false, true] { let scheme = dark ? "dark" : "light" for layout in ["grid", "speaker"] { let app = launchCall( "connecting", ["-CallsFixtureControls", "hidden", "-CallsFixtureLayout", layout, "-CallsFixtureOrder", "sam,bea,ana,me"], dark: dark) let notice = app.descendants(matching: .any) .matching(NSPredicate(format: "label CONTAINS %@", "Samuel Okafor is connecting and can’t hear you yet")) .firstMatch XCTAssertTrue(notice.waitForExistence(timeout: 15), "Samuel's tile says it") XCTAssertFalse(notice.label.contains("…"), "and VoiceOver hears no dots") Thread.sleep(forTimeInterval: 1) try snapshot(app, "connecting-\(layout)-portrait-\(scheme)") } } } /// The overlay over black, white and a busy frame, in each of your own /// states, light and dark: it should look the same in both. The /// microphone sits beside the menu here so its muted state shows. @MainActor func testOverlayContrastSnapshots() throws { XCUIDevice.shared.orientation = .portrait for dark in [false, true] { for backdrop in ["black", "white", "busy"] { for me in ["normal", "muted", "cameraOff", "sharing"] { let app = launchCall( "focus", [ "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop, "-CallsFixtureSelf", me, "-CallsMicBesideMenu", "YES", ], dark: dark) XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) XCTAssertTrue(app.buttons["mute"].exists, "beside the menu") if me == "muted" { XCTAssertEqual(app.buttons["mute"].label, "Unmute microphone") } Thread.sleep(forTimeInterval: 1) try snapshot(app, "contrast-\(backdrop)-\(me)-\(dark ? "dark" : "light")") } } let notices = launchCall( "notices", ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "white"], dark: dark) XCTAssertTrue(notices.buttons["Let in"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(notices, "contrast-white-notices-\(dark ? "dark" : "light")") } } /// 0.4.3: the warm rim on a name at four levels, over the rim mock-up's /// dark and bright tiles, white, and the busy frame (`WarmRimGallery`). @MainActor func testWarmRimSnapshots() throws { XCUIDevice.shared.orientation = .portrait for level in ["silent", "low", "mid", "high"] { let app = XCUIApplication() app.launchArguments += ["-CallsFixtureRimGallery", level] app.launch() XCTAssertTrue(app.staticTexts["Vicky Walsh"].firstMatch.waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(app, "rim-\(level)") } } /// 0.3.7: the names glowing at four levels, in the grid and in focus, /// and the glass over black, white and a busy frame, in Liquid Glass and /// in the material older systems draw instead. @MainActor func testGlowAndGlassSnapshots() throws { XCUIDevice.shared.orientation = .portrait for level in ["silent", "low", "mid", "high"] { for fixture in ["grid", "focus"] { let app = launchCall( fixture, ["-CallsFixtureControls", "shown", "-CallsFixtureSpeaking", level], dark: true) XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(app, "glow-\(fixture)-\(level)") } } for glass in [true, false] { for backdrop in ["black", "white", "busy"] { let app = launchCall( "notices", [ "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop, "-CallsMicBesideMenu", "YES", "-CallsFixtureNoGlass", glass ? "NO" : "YES", ], dark: true) XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(app, "bar-\(glass ? "glass" : "fallback")-\(backdrop)") } } } /// 0.4.0: Speaker view in a call of four. Tom has the floor and the /// other three, you included, are in the filmstrip; a tap on Li pins Li /// to the stage with a Pinned badge, and the badge unpins. The layout is /// in the ⋯ sheet as Speaker and Grid. @MainActor func testSpeakerViewPinsWithATap() throws { XCUIDevice.shared.orientation = .portrait let app = launchCall( "grid", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "shown"]) let filmstrip = app.descendants(matching: .any)["filmstrip"] XCTAssertTrue(filmstrip.waitForExistence(timeout: 15)) let stage = app.descendants(matching: .any).matching( NSPredicate(format: "label BEGINSWITH %@", "On stage: ")).firstMatch XCTAssertTrue(stage.waitForExistence(timeout: 5)) XCTAssertTrue(stage.label.hasPrefix("On stage: Tom Byrne"), stage.label) for name in ["You", "Ana García", "Li Wei"] { XCTAssertTrue( filmstrip.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", name)) .firstMatch.exists, "\(name) is in the filmstrip") } XCTAssertFalse(app.descendants(matching: .any)["selfView"].exists, "you are in the filmstrip, not floating") filmstrip.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "Li Wei")) .firstMatch.tap() let unpin = app.buttons["unpin"] XCTAssertTrue(unpin.waitForExistence(timeout: 3), "a pinned stage shows its pin") XCTAssertTrue(stage.label.hasPrefix("On stage: Li Wei"), stage.label) XCTAssertTrue(stage.label.hasSuffix("pinned"), stage.label) unpin.tap() XCTAssertTrue( app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "On stage: Tom Byrne")) .firstMatch.waitForExistence(timeout: 3), "unpinned, the speaker has the stage again") // The layout, in the controls sheet. app.buttons["moreControls"].tap() XCTAssertTrue(app.buttons["videoLayout"].waitForExistence(timeout: 5)) app.buttons["videoLayout"].tap() XCTAssertTrue(app.buttons["Speaker"].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons["Grid"].exists) XCTAssertFalse(app.buttons["Focus"].exists, "Focus is now Speaker") } /// A tile's accessibility actions move it without a drag: Move to first /// on Li in Grid puts Li before you. @MainActor func testTilesMoveWithoutADrag() throws { XCUIDevice.shared.orientation = .portrait let app = launchCall("grid", ["-CallsFixtureControls", "hidden"]) let li = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "Li Wei")).firstMatch let me = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "You")).firstMatch XCTAssertTrue(li.waitForExistence(timeout: 15)) XCTAssertGreaterThan(li.frame.minY + li.frame.minX / 1000, me.frame.minY + me.frame.minX / 1000, "Li starts after you") let centre = CGVector(dx: 0.5, dy: 0.5) li.coordinate(withNormalizedOffset: centre).press( forDuration: 0.8, thenDragTo: me.coordinate(withNormalizedOffset: centre), withVelocity: .slow, thenHoldForDuration: 0.5) Thread.sleep(forTimeInterval: 1) XCTAssertLessThanOrEqual( li.frame.minY * 1000 + li.frame.minX, me.frame.minY * 1000 + me.frame.minX, "a long press and drag onto your place puts Li there") } /// Snapshots of Speaker view and the order (0.4.0), portrait and /// landscape: the filmstrip of three, a pin, a drag half way, and Grid /// after a reorder. @MainActor func testSpeakerViewSnapshots() throws { defer { XCUIDevice.shared.orientation = .portrait } for orientation in [UIDeviceOrientation.portrait, .landscapeLeft] { XCUIDevice.shared.orientation = orientation let turn = orientation == .portrait ? "portrait" : "landscape" let cases: [(String, [String])] = [ ("speaker-filmstrip", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "hidden"]), ("speaker-controls", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "shown"]), ("speaker-pinned", ["-CallsFixtureLayout", "speaker", "-CallsFixturePinned", "li", "-CallsFixtureControls", "hidden"]), ("speaker-drag", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureLift", "li", "-CallsFixtureControls", "hidden"]), ("grid-drag", ["-CallsFixtureLayout", "grid", "-CallsFixtureLift", "li", "-CallsFixtureControls", "hidden"]), ("grid-reordered", ["-CallsFixtureLayout", "grid", "-CallsFixtureOrder", "li,tom,me,ana", "-CallsFixtureControls", "hidden"]), ] for (name, extra) in cases { let app = launchCall("grid", extra, dark: true) XCTAssertTrue(app.descendants(matching: .any)["callStage"].firstMatch.waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) try snapshot(app, "\(name)-\(turn)") } } } /// Landscape keeps both bar buttons on screen, clear of the Dynamic /// Island's side. @MainActor func testLandscapeKeepsTheBarOnScreen() throws { XCUIDevice.shared.orientation = .landscapeLeft defer { XCUIDevice.shared.orientation = .portrait } let app = launchCall("focus", ["-CallsFixtureControls", "shown"]) XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) XCTAssertTrue(app.buttons["leave"].isHittable) XCTAssertTrue(app.buttons["moreControls"].isHittable) XCTAssertLessThan(app.buttons["moreControls"].frame.midX, app.buttons["leave"].frame.midX) } // MARK: Glyphs (0.4.1) /// Paul, on the 0.4.0 bar: "Are these vertically aligned?" Every /// symbol a call control draws goes through CallGlyph and its optical /// table; the Mac's tests measure it drawn by AppKit, and this measures /// it as the iPhone draws it: the round bar buttons, the microphone /// beside the menu, and every button in the controls sheet, each /// symbol's ink within half a point of its button's centre line. @MainActor func testControlGlyphsAreOnTheirCentreLines() throws { XCUIDevice.shared.orientation = .portrait var report: [String] = [] var failures: [String] = [] func measure(_ app: XCUIApplication, _ ids: [String], _ label: String) { let shot = XCUIScreen.main.screenshot() for id in ids { let button = app.buttons[id].firstMatch guard button.exists else { failures.append("\(label): \(id) is not there") continue } guard let offset = GlyphInk.offset(in: shot.image, frame: button.frame) else { failures.append("\(label): \(id) drew no symbol") continue } report.append(String(format: "%@ %@: %+.2f pt", label, id, offset)) if abs(offset) > 0.5 { failures.append(String(format: "%@ %@: %+.2f pt", label, id, offset)) } } } let bar = launchCall( "focus", ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "black", "-CallsMicBesideMenu", "YES"], dark: true) XCTAssertTrue(bar.buttons["moreControls"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1) measure(bar, ["moreControls", "mute", "leave"], "bar") bar.terminate() for dark in [false, true] { let sheet = launchCall("focus", ["-CallsFixtureControls", "shown", "-CallsFixtureMore", "YES"], dark: dark) XCTAssertTrue(sheet.buttons["devices"].waitForExistence(timeout: 15)) Thread.sleep(forTimeInterval: 1.5) measure( sheet, ["mute", "camera", "flipCamera", "devices", "micModeQuick", "videoLayout", "Share meeting link", "openChat"], dark ? "sheet dark" : "sheet light") sheet.terminate() } let attachment = XCTAttachment(string: report.joined(separator: "\n")) attachment.name = "glyph centroids" attachment.lifetime = .keepAlways add(attachment) try FileManager.default.createDirectory(at: snapshotDirectory, withIntermediateDirectories: true) try report.joined(separator: "\n").write( to: snapshotDirectory.appendingPathComponent("iphone-glyph-centroids.txt"), atomically: true, encoding: .utf8) XCTAssert(failures.isEmpty, failures.joined(separator: "\n") + "\n" + report.joined(separator: "\n")) } } /// Where a button's symbol's ink is: the pixels of its frame weighted by /// how far their colour is from the button's own background, sampled /// just inside its left edge at mid-height (a round button's glass, a /// tinted square's fill, a red capsule), as the tests on the Mac do. enum GlyphInk { static func offset(in image: UIImage, frame: CGRect) -> CGFloat? { guard let cg = image.cgImage else { return nil } let scale = CGFloat(cg.width) / image.size.width let rect = CGRect( x: (frame.minX * scale).rounded(), y: (frame.minY * scale).rounded(), width: (frame.width * scale).rounded(), height: (frame.height * scale).rounded()) guard let crop = cg.cropping(to: rect) else { return nil } let w = crop.width, h = crop.height var px = [UInt8](repeating: 0, count: w * h * 4) guard let ctx = CGContext( data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } ctx.draw(crop, in: CGRect(x: 0, y: 0, width: w, height: h)) // The background: the median of a short column just inside the // left edge, clear of the symbol and of a rounded corner. let x = max(2, Int(4 * scale)) var samples: [(Int, Int, Int)] = [] for y in (h / 2 - Int(3 * scale))...(h / 2 + Int(3 * scale)) { let i = (y * w + x) * 4 samples.append((Int(px[i]), Int(px[i + 1]), Int(px[i + 2]))) } samples.sort { $0.0 + $0.1 + $0.2 < $1.0 + $1.1 + $1.2 } let bg = samples[samples.count / 2] var total = 0.0, moment = 0.0 // Only the middle of the button, where the symbol is: a square's // rounded corners and a circle's edge stay out of it. let inset = Int(8 * scale) for y in inset..<(h - inset) { for x in inset..<(w - inset) { let i = (y * w + x) * 4 let d = abs(Int(px[i]) - bg.0) + abs(Int(px[i + 1]) - bg.1) + abs(Int(px[i + 2]) - bg.2) let weight = Double(max(0, d - 60)) total += weight moment += weight * (Double(y) + 0.5) } } guard total > 0 else { return nil } return CGFloat(moment / total) / scale - frame.height / 2 + (rect.minY / scale - frame.minY) } }