Photograph the iPad layout in every orientation it has to support

App Store Connect requires an iPad build to support all four
orientations unless it gives up Split View and Slide Over, so every
screen has to survive being wide and short. That was the one part of
the iPad change nobody had seen.

Rotating the simulator window from outside does not reach iOS at all.
The window turned; the device kept reporting portrait, the status bar
stayed on the short edge, and the app never received an orientation
change. So this rotates from inside, with `XCUIDevice.shared.orientation`,
which is the one rotation the app is guaranteed to get.

It signs in, photographs Home, Messages, Calls and Account both ways up,
then opens a conversation into the right-hand column and photographs it
in portrait, both landscapes, upside down, and in landscape with the
keyboard up, which is the case most likely to leave the transcript no
room. All of them hold: the list stays on the left, the conversation on
the right, and with the keyboard up the composer and header stay visible
while the transcript shrinks.

Credentials come from the environment only, since this repository is
public. It skips on a phone rather than photographing the phone layout
and calling it iPad.

`ipad-build/` is ignored alongside `ci-build/`, for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-11 11:48:45 -04:00
co-authored by Claude Opus 5
parent 6efe950649
commit ac52500521
2 changed files with 143 additions and 0 deletions
+3
View File
@@ -11,6 +11,9 @@ xcuserdata/
# — that matches a directory named exactly "build" — and 3,500 files of index
# and compilation cache were staged once because of it.
ci-build/
# The iPad simulator build used to photograph the two-column layout. The same
# derived-data shape as ci-build, and just as large.
ipad-build/
# Same trap, second spelling: the signed device build uses
# `-derivedDataPath device-build`. Any new derived-data path needs a line here.
+140
View File
@@ -0,0 +1,140 @@
import XCTest
/// Photographs the iPad layout in every orientation App Store Connect requires.
///
/// An iPad build has to support all four orientations unless it gives up Split
/// View and Slide Over, so every screen here has to survive being wide and
/// short. Rotating the simulator window from outside turns out not to reach
/// iOS at all: the device kept reporting portrait while the window looked
/// sideways. Setting `XCUIDevice.shared.orientation` from inside the test is
/// the one rotation the app is guaranteed to receive.
///
/// Credentials come from the environment and nowhere else, because this
/// repository is public:
///
/// env TEST_RUNNER_FN_TEST_HANDLE=... TEST_RUNNER_FN_TEST_PASSWORD=... \
/// TEST_RUNNER_FN_TEST_PEER=... xcodebuild test ...
///
/// `FN_TEST_PEER` is optional. Without it the conversation shots are skipped
/// and the lists are still photographed.
final class IPadLayoutUITests: XCTestCase {
private func env(_ key: String) -> String? {
let value = ProcessInfo.processInfo.environment[key]?
.trimmingCharacters(in: .whitespacesAndNewlines)
return (value?.isEmpty ?? true) ? nil : value
}
private func shot(_ name: String) {
// `XCUIScreen.main`, not `app.screenshot()`: the app's own screenshot
// omits system chrome, and whether the status bar moved to the long edge
// is part of what a rotation shot is checking.
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
/// Rotate, and give the layout time to finish animating before anything
/// photographs it half-turned.
private func rotate(_ orientation: UIDeviceOrientation) {
XCUIDevice.shared.orientation = orientation
Thread.sleep(forTimeInterval: 2.5)
}
@discardableResult
private func openTab(_ app: XCUIApplication, _ name: String) -> Bool {
let tab = app.buttons["tab.\(name)"]
guard tab.waitForExistence(timeout: 20) else { return false }
tab.tap()
Thread.sleep(forTimeInterval: 2)
return true
}
override func setUpWithError() throws {
continueAfterFailure = true
try XCTSkipUnless(
UIDevice.current.userInterfaceIdiom == .pad,
"an iPad layout test on a phone would photograph the phone layout and call it iPad")
try XCTSkipUnless(
env("FN_TEST_HANDLE") != nil && env("FN_TEST_PASSWORD") != nil,
"set TEST_RUNNER_FN_TEST_HANDLE and TEST_RUNNER_FN_TEST_PASSWORD to sign in")
}
override func tearDown() {
// Leave the simulator upright for whatever runs next.
XCUIDevice.shared.orientation = .portrait
super.tearDown()
}
func testEveryOrientation() throws {
let handle = try XCTUnwrap(env("FN_TEST_HANDLE"))
let password = try XCTUnwrap(env("FN_TEST_PASSWORD"))
XCUIDevice.shared.orientation = .portrait
let app = XCUIApplication()
app.launchArguments = ["-FNMSGResetState"]
app.launch()
// Notification and microphone prompts arrive as SpringBoard alerts and
// swallow every tap until answered.
addUIInterruptionMonitor(withDescription: "permissions") { alert in
for label in ["Allow", "OK", "Allow While Using App"] where alert.buttons[label].exists {
alert.buttons[label].tap()
return true
}
return false
}
app.completeSignIn(username: handle, password: password, reachesShell: 150)
app.waitForAndDismissPasswordPanel(timeout: 15)
// The monitor only fires on the next interaction with the app.
app.tap()
// Lists beside the empty right-hand column, both ways up.
for tab in ["home", "messages", "calls", "account"] {
guard openTab(app, tab) else {
XCTFail("no \(tab) tab to open")
continue
}
rotate(.portrait)
shot("\(tab)-portrait")
rotate(.landscapeLeft)
shot("\(tab)-landscape")
rotate(.portrait)
}
// A conversation in the right-hand column, which is the point of the
// layout, and then the composer with the keyboard up in landscape,
// which is the case most likely to leave the transcript no room at all.
guard let peer = env("FN_TEST_PEER") else { return }
openTab(app, "messages")
let row = [app.buttons[peer], app.staticTexts[peer]]
.first { $0.waitForExistence(timeout: 10) }
guard let row else {
XCTFail("'\(peer)' is not in the Messages list, so there is no conversation to open")
return
}
row.tap()
Thread.sleep(forTimeInterval: 2)
rotate(.portrait)
shot("chat-portrait")
rotate(.landscapeLeft)
shot("chat-landscape")
let composer = app.textFields["chat.composer"]
if composer.waitForExistence(timeout: 10) {
composer.tap()
Thread.sleep(forTimeInterval: 2)
shot("chat-landscape-keyboard")
} else {
XCTFail("no composer in the right-hand column")
}
rotate(.landscapeRight)
shot("chat-landscape-right")
rotate(.portraitUpsideDown)
shot("chat-upside-down")
}
}