379 lines
12 KiB
Swift
379 lines
12 KiB
Swift
//
|
|
// TheNoiseClockUITests.swift
|
|
// TheNoiseClockUITests
|
|
//
|
|
// Created by Matt Bruce on 9/7/25.
|
|
//
|
|
|
|
import XCTest
|
|
|
|
final class TheNoiseClockUITests: XCTestCase {
|
|
|
|
override func setUpWithError() throws {
|
|
continueAfterFailure = false
|
|
}
|
|
|
|
@MainActor
|
|
func testAppStoreScreenshots_iPhone69() throws {
|
|
let app = XCUIApplication()
|
|
app.launchArguments += [
|
|
"-onboarding.TheNoiseClock.hasCompletedWelcome", "YES",
|
|
"-onboarding.TheNoiseClock.hasLaunched", "YES"
|
|
]
|
|
app.launch()
|
|
|
|
dismissOnboardingIfNeeded(app)
|
|
ensureKeepAwakeEnabled(app)
|
|
ensureAlarmExistsAndEnabled(app)
|
|
|
|
captureNoiseViewPortraitAndLandscape(app)
|
|
captureClockPortraitAndLandscape(app)
|
|
captureAlarmsViewPortraitAndLandscape(app)
|
|
captureSettingsViewPortraitAndLandscape(app)
|
|
|
|
XCUIDevice.shared.orientation = .portrait
|
|
}
|
|
|
|
// MARK: - Capture Steps
|
|
|
|
@MainActor
|
|
private func captureClockPortraitAndLandscape(_ app: XCUIApplication) {
|
|
XCUIDevice.shared.orientation = .portrait
|
|
sleep(1)
|
|
openTab(named: "Clock", in: app)
|
|
waitForClockFullscreenTransition()
|
|
saveScreenshot(named: "01-clock-view-portrait.png")
|
|
|
|
XCUIDevice.shared.orientation = .landscapeLeft
|
|
sleep(2)
|
|
openTab(named: "Clock", in: app)
|
|
waitForClockFullscreenTransition()
|
|
saveScreenshot(named: "02-clock-view-landscape.png")
|
|
}
|
|
|
|
@MainActor
|
|
private func captureAlarmsViewPortraitAndLandscape(_ app: XCUIApplication) {
|
|
XCUIDevice.shared.orientation = .portrait
|
|
sleep(1)
|
|
openTab(named: "Alarms", in: app)
|
|
sleep(1)
|
|
saveScreenshot(named: "03-alarms-view-portrait.png")
|
|
|
|
XCUIDevice.shared.orientation = .landscapeLeft
|
|
sleep(2)
|
|
openTab(named: "Alarms", in: app)
|
|
sleep(1)
|
|
saveScreenshot(named: "04-alarms-view-landscape.png")
|
|
}
|
|
|
|
@MainActor
|
|
private func captureNoiseViewPortraitAndLandscape(_ app: XCUIApplication) {
|
|
XCUIDevice.shared.orientation = .portrait
|
|
sleep(1)
|
|
openTab(named: "Noise", in: app)
|
|
ensureNoiseSoundSelectedAndPlaying(app)
|
|
sleep(1)
|
|
saveScreenshot(named: "05-noise-view-portrait.png")
|
|
|
|
XCUIDevice.shared.orientation = .landscapeLeft
|
|
sleep(2)
|
|
openTab(named: "Noise", in: app)
|
|
ensureNoiseSoundSelectedAndPlaying(app)
|
|
sleep(1)
|
|
saveScreenshot(named: "06-noise-view-landscape.png")
|
|
}
|
|
|
|
@MainActor
|
|
private func captureSettingsViewPortraitAndLandscape(_ app: XCUIApplication) {
|
|
XCUIDevice.shared.orientation = .portrait
|
|
sleep(1)
|
|
openTab(named: "Settings", in: app)
|
|
sleep(1)
|
|
saveScreenshot(named: "07-clock-settings-portrait.png")
|
|
|
|
XCUIDevice.shared.orientation = .landscapeLeft
|
|
sleep(2)
|
|
openTab(named: "Settings", in: app)
|
|
sleep(1)
|
|
saveScreenshot(named: "08-clock-settings-landscape.png")
|
|
}
|
|
|
|
@MainActor
|
|
private func ensureNoiseSoundSelectedAndPlaying(_ app: XCUIApplication) {
|
|
// Select a known sound so controls become visible.
|
|
let soundNames = [
|
|
"White Noise",
|
|
"Brown Noise",
|
|
"Heavy Rain",
|
|
"Atmospheric Pad",
|
|
"Fan Heater"
|
|
]
|
|
var didSelectSound = false
|
|
for _ in 0..<8 {
|
|
for soundName in soundNames {
|
|
let sound = app.staticTexts[soundName]
|
|
if sound.exists && sound.isHittable {
|
|
sound.tap()
|
|
didSelectSound = true
|
|
break
|
|
}
|
|
}
|
|
if didSelectSound {
|
|
break
|
|
}
|
|
app.swipeUp()
|
|
usleep(250_000)
|
|
}
|
|
|
|
let playButton = app.buttons["noise.playStopButton"]
|
|
if playButton.waitForExistence(timeout: 4) {
|
|
playButton.tap()
|
|
}
|
|
}
|
|
|
|
// MARK: - State Setup
|
|
|
|
@MainActor
|
|
private func dismissOnboardingIfNeeded(_ app: XCUIApplication) {
|
|
let secondaryButton = app.buttons["onboarding.secondaryButton"]
|
|
if secondaryButton.waitForExistence(timeout: 3), secondaryButton.label == "Skip" {
|
|
secondaryButton.tap()
|
|
waitForMainTabs(app)
|
|
return
|
|
}
|
|
if app.buttons["Skip"].waitForExistence(timeout: 3) {
|
|
app.buttons["Skip"].tap()
|
|
waitForMainTabs(app)
|
|
return
|
|
}
|
|
|
|
// Fallback: walk onboarding pages if skip is not available.
|
|
for _ in 0..<6 {
|
|
if hasMainTabs(app) { return }
|
|
let next = app.buttons["Next"]
|
|
let getStarted = app.buttons["Get Started"]
|
|
|
|
if getStarted.exists {
|
|
getStarted.tap()
|
|
waitForMainTabs(app)
|
|
return
|
|
}
|
|
if next.exists {
|
|
next.tap()
|
|
usleep(300_000)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func ensureKeepAwakeEnabled(_ app: XCUIApplication) {
|
|
openTab(named: "Settings", in: app)
|
|
|
|
let keepAwakeSwitch = app.switches["settings.keepAwake.toggle"]
|
|
for _ in 0..<8 {
|
|
if keepAwakeSwitch.exists { break }
|
|
app.swipeUp()
|
|
usleep(200_000)
|
|
}
|
|
|
|
guard keepAwakeSwitch.waitForExistence(timeout: 3) else {
|
|
XCTFail("Could not find Keep Awake toggle by accessibility identifier.")
|
|
return
|
|
}
|
|
|
|
if !isSwitchOn(keepAwakeSwitch) {
|
|
keepAwakeSwitch.tap()
|
|
sleep(1)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func ensureAlarmExistsAndEnabled(_ app: XCUIApplication) {
|
|
openTab(named: "Alarms", in: app)
|
|
|
|
// If no alarms exist, create one with defaults.
|
|
let firstSwitch = app.switches.firstMatch
|
|
if !firstSwitch.waitForExistence(timeout: 2) {
|
|
tapNavigationPlusButton(in: app)
|
|
|
|
let saveButton = app.buttons["alarms.add.saveButton"]
|
|
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Add Alarm sheet did not appear.")
|
|
saveButton.tap()
|
|
sleep(1)
|
|
}
|
|
|
|
// Make sure at least one alarm is enabled.
|
|
if firstSwitch.waitForExistence(timeout: 3), !isSwitchOn(firstSwitch) {
|
|
firstSwitch.tap()
|
|
sleep(1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
@MainActor
|
|
private func openTab(named tabName: String, in app: XCUIApplication) {
|
|
let expectedIndex: Int? = switch tabName {
|
|
case "Clock": 0
|
|
case "Alarms": 1
|
|
case "Noise": 2
|
|
case "Settings": 3
|
|
default: nil
|
|
}
|
|
|
|
func tapByCoordinates(_ element: XCUIElement) {
|
|
let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
.withOffset(CGVector(dx: element.frame.midX, dy: element.frame.midY))
|
|
coordinate.tap()
|
|
}
|
|
|
|
func tapLabeledButtonAnywhere() -> Bool {
|
|
let matches = app.buttons.matching(NSPredicate(format: "label == %@", tabName))
|
|
.allElementsBoundByIndex
|
|
.filter(\.exists)
|
|
guard !matches.isEmpty else { return false }
|
|
|
|
if let hittable = matches.first(where: \.isHittable) {
|
|
hittable.tap()
|
|
return true
|
|
}
|
|
|
|
tapByCoordinates(matches[0])
|
|
return true
|
|
}
|
|
|
|
func tapLabeledButton() -> Bool {
|
|
let query = app.tabBars.buttons.matching(NSPredicate(format: "label == %@", tabName))
|
|
let matches = query.allElementsBoundByIndex.filter(\.exists)
|
|
guard !matches.isEmpty else { return false }
|
|
|
|
if let hittable = matches.first(where: \.isHittable) {
|
|
hittable.tap()
|
|
return true
|
|
}
|
|
|
|
tapByCoordinates(matches[0])
|
|
return true
|
|
}
|
|
|
|
func tapIndexedButton() -> Bool {
|
|
guard let expectedIndex else { return false }
|
|
let tabBar = app.tabBars.firstMatch
|
|
guard tabBar.exists else { return false }
|
|
let buttons = tabBar.buttons
|
|
guard buttons.count > expectedIndex else { return false }
|
|
let button = buttons.element(boundBy: expectedIndex)
|
|
guard button.exists else { return false }
|
|
button.tap()
|
|
return true
|
|
}
|
|
|
|
if tapLabeledButton() || tapIndexedButton() {
|
|
return
|
|
}
|
|
|
|
// Fallback for cases where tab buttons are not exposed under tabBars.
|
|
if tapLabeledButtonAnywhere() {
|
|
return
|
|
}
|
|
|
|
// Reveal tab bar when clock is in auto full-screen mode.
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
usleep(300_000)
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)).tap()
|
|
usleep(300_000)
|
|
|
|
if tapLabeledButton() || tapIndexedButton() || tapLabeledButtonAnywhere() {
|
|
return
|
|
}
|
|
|
|
dismissInterferingPromptIfPresent(in: app)
|
|
if tapLabeledButton() || tapIndexedButton() || tapLabeledButtonAnywhere() {
|
|
return
|
|
}
|
|
|
|
let buttonLabels = app.buttons.allElementsBoundByIndex
|
|
.prefix(16)
|
|
.map(\.label)
|
|
.joined(separator: ", ")
|
|
XCTFail("Could not open tab '\(tabName)' by label or index. tabBars=\(app.tabBars.count), buttons=[\(buttonLabels)]")
|
|
}
|
|
|
|
@MainActor
|
|
private func tapNavigationPlusButton(in app: XCUIApplication) {
|
|
let plusCandidates = [
|
|
app.buttons["alarms.addButton"],
|
|
app.navigationBars.buttons["plus"],
|
|
app.navigationBars.buttons["Add"],
|
|
app.buttons["plus"]
|
|
]
|
|
|
|
for candidate in plusCandidates where candidate.exists {
|
|
candidate.tap()
|
|
return
|
|
}
|
|
|
|
XCTFail("Could not find add (+) button.")
|
|
}
|
|
|
|
@MainActor
|
|
private func dismissInterferingPromptIfPresent(in app: XCUIApplication) {
|
|
guard app.tabBars.count == 0 else { return }
|
|
let buttons = app.buttons.allElementsBoundByIndex.filter { $0.exists && $0.isHittable }
|
|
guard buttons.count == 2 else { return }
|
|
|
|
// Keep Awake prompt can obscure tabs in some locale/device combinations.
|
|
buttons[1].tap()
|
|
usleep(300_000)
|
|
}
|
|
|
|
private func isSwitchOn(_ toggle: XCUIElement) -> Bool {
|
|
guard let value = toggle.value as? String else { return false }
|
|
return value == "1" || value.lowercased() == "on"
|
|
}
|
|
|
|
private func waitForClockFullscreenTransition() {
|
|
// Clock auto-hides chrome after 5 seconds of inactivity.
|
|
sleep(6)
|
|
}
|
|
|
|
private func saveScreenshot(named fileName: String) {
|
|
let screenshot = XCUIScreen.main.screenshot()
|
|
let data = screenshot.pngRepresentation
|
|
let destination = screenshotDirectory().appendingPathComponent(fileName)
|
|
|
|
do {
|
|
try data.write(to: destination)
|
|
} catch {
|
|
XCTFail("Failed to write screenshot to \(destination.path): \(error)")
|
|
}
|
|
|
|
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
|
|
attachment.name = fileName
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
|
|
private func screenshotDirectory() -> URL {
|
|
let env = ProcessInfo.processInfo.environment["SCREENSHOT_OUTPUT_DIR"]
|
|
let path = (env?.isEmpty == false) ? env! : NSTemporaryDirectory()
|
|
let url = URL(fileURLWithPath: path, isDirectory: true)
|
|
|
|
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
return url
|
|
}
|
|
|
|
private func waitForMainTabs(_ app: XCUIApplication) {
|
|
for _ in 0..<20 {
|
|
if hasMainTabs(app) { return }
|
|
usleep(200_000)
|
|
}
|
|
}
|
|
|
|
private func hasMainTabs(_ app: XCUIApplication) -> Bool {
|
|
let labels = ["Clock", "Alarms", "Noise", "Settings"]
|
|
if app.tabBars.count > 0 { return true }
|
|
return labels.contains(where: { app.buttons[$0].exists })
|
|
}
|
|
}
|