539 lines
18 KiB
Swift
539 lines
18 KiB
Swift
//
|
||
// TheNoiseClockUITests.swift
|
||
// TheNoiseClockUITests
|
||
//
|
||
// Created by Matt Bruce on 9/7/25.
|
||
//
|
||
|
||
import XCTest
|
||
|
||
final class TheNoiseClockUITests: XCTestCase {
|
||
private enum CaptureTab: CaseIterable {
|
||
case clock
|
||
case alarms
|
||
case noise
|
||
case settings
|
||
|
||
var index: Int {
|
||
switch self {
|
||
case .clock: 0
|
||
case .alarms: 1
|
||
case .noise: 2
|
||
case .settings: 3
|
||
}
|
||
}
|
||
|
||
var labels: [String] {
|
||
switch self {
|
||
case .clock:
|
||
["Clock", "Horloge", "Reloj"]
|
||
case .alarms:
|
||
["Alarms", "Alarmes", "Alarmas"]
|
||
case .noise:
|
||
["Noise", "Bruit", "Ruido"]
|
||
case .settings:
|
||
["Settings", "Reglages", "Réglages", "Configuracion", "Configuración", "Ajustes"]
|
||
}
|
||
}
|
||
|
||
var symbolLabels: [String] {
|
||
switch self {
|
||
case .clock: ["clock", "clock.fill"]
|
||
case .alarms: ["alarm", "alarm.fill"]
|
||
case .noise: ["waveform", "speaker.wave.2.fill", "speaker.wave.2"]
|
||
case .settings: ["gearshape", "gearshape.fill"]
|
||
}
|
||
}
|
||
|
||
var screenIdentifier: String {
|
||
switch self {
|
||
case .clock: "clock.screen"
|
||
case .alarms: "alarms.screen"
|
||
case .noise: "noise.screen"
|
||
case .settings: "settings.screen"
|
||
}
|
||
}
|
||
}
|
||
|
||
override func setUpWithError() throws {
|
||
continueAfterFailure = false
|
||
}
|
||
|
||
@MainActor
|
||
func testAppStoreScreenshots_iPhone69() throws {
|
||
let app = XCUIApplication()
|
||
app.launchArguments += [
|
||
"-onboarding.TheNoiseClock.hasCompletedWelcome", "YES",
|
||
"-onboarding.TheNoiseClock.hasLaunched", "YES",
|
||
"-uiTest.bypassAlarmKit", "YES",
|
||
"-uiTest.autoPlayNoise", "YES"
|
||
]
|
||
app.launch()
|
||
|
||
dismissOnboardingIfNeeded(app)
|
||
ensureKeepAwakeEnabled(app)
|
||
|
||
captureClock(app)
|
||
captureAlarmsView(app)
|
||
captureNoiseView(app)
|
||
captureSettingsView(app)
|
||
|
||
XCUIDevice.shared.orientation = .portrait
|
||
}
|
||
|
||
// MARK: - Capture Steps
|
||
|
||
@MainActor
|
||
private func captureClock(_ app: XCUIApplication) {
|
||
XCUIDevice.shared.orientation = .portrait
|
||
sleep(1)
|
||
ensureCapturePreconditions(app)
|
||
openTab(.clock, in: app)
|
||
waitForClockFullscreenTransition()
|
||
saveScreenshot(named: "01-clock-view-portrait.png")
|
||
|
||
XCUIDevice.shared.orientation = .landscapeLeft
|
||
sleep(2)
|
||
ensureCapturePreconditions(app)
|
||
openTab(.clock, in: app)
|
||
waitForClockFullscreenTransition()
|
||
saveScreenshot(named: "02-clock-view-landscape.png")
|
||
}
|
||
|
||
@MainActor
|
||
private func captureAlarmsView(_ app: XCUIApplication) {
|
||
XCUIDevice.shared.orientation = .portrait
|
||
sleep(1)
|
||
ensureCapturePreconditions(app)
|
||
openTab(.alarms, in: app)
|
||
sleep(1)
|
||
saveScreenshot(named: "03-alarms-view-portrait.png")
|
||
}
|
||
|
||
@MainActor
|
||
private func captureNoiseView(_ app: XCUIApplication) {
|
||
XCUIDevice.shared.orientation = .portrait
|
||
sleep(1)
|
||
ensureCapturePreconditions(app)
|
||
openTab(.noise, in: app)
|
||
sleep(1)
|
||
saveScreenshot(named: "04-noise-view-portrait.png")
|
||
}
|
||
|
||
@MainActor
|
||
private func captureSettingsView(_ app: XCUIApplication) {
|
||
XCUIDevice.shared.orientation = .portrait
|
||
sleep(1)
|
||
ensureCapturePreconditions(app)
|
||
openTab(.settings, in: app)
|
||
sleep(1)
|
||
saveScreenshot(named: "05-clock-settings-portrait.png")
|
||
|
||
}
|
||
|
||
@MainActor
|
||
private func ensureCapturePreconditions(_ app: XCUIApplication) {
|
||
ensureAlarmExistsAndEnabled(app)
|
||
ensureNoiseSoundSelectedAndPlaying(app)
|
||
}
|
||
|
||
@MainActor
|
||
private func ensureNoiseSoundSelectedAndPlaying(_ app: XCUIApplication) {
|
||
openTab(.noise, in: app)
|
||
guard waitForScreen(.noise, in: app) else {
|
||
XCTFail("Could not navigate to noise screen before selecting a sound.")
|
||
return
|
||
}
|
||
|
||
let playButton = noisePlayControl(in: app)
|
||
guard playButton.exists else {
|
||
return
|
||
}
|
||
|
||
if !isNoiseCurrentlyPlaying(playButton) {
|
||
playButton.tap()
|
||
usleep(300_000)
|
||
}
|
||
}
|
||
|
||
private func noisePlayControl(in app: XCUIApplication) -> XCUIElement {
|
||
app.descendants(matching: .any).matching(identifier: "noise.playStopButton").firstMatch
|
||
}
|
||
|
||
private func isNoiseCurrentlyPlaying(_ playButton: XCUIElement) -> Bool {
|
||
let normalizedLabel = normalized(playButton.label)
|
||
let normalizedValue = normalized((playButton.value as? String) ?? "")
|
||
|
||
let stopKeywords = ["stop", "arrêter", "arreter", "detener"]
|
||
let playKeywords = ["play", "lire", "jouer", "reproducir"]
|
||
|
||
if stopKeywords.contains(where: { normalizedLabel.contains(normalized($0)) || normalizedValue.contains(normalized($0)) }) {
|
||
return true
|
||
}
|
||
if playKeywords.contains(where: { normalizedLabel.contains(normalized($0)) || normalizedValue.contains(normalized($0)) }) {
|
||
return false
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
private func normalized(_ text: String) -> String {
|
||
text
|
||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||
.lowercased()
|
||
}
|
||
|
||
// MARK: - State Setup
|
||
|
||
@MainActor
|
||
private func dismissOnboardingIfNeeded(_ app: XCUIApplication) {
|
||
let secondaryButton = app.buttons["onboarding.secondaryButton"]
|
||
if secondaryButton.waitForExistence(timeout: 3) {
|
||
secondaryButton.tap()
|
||
waitForMainTabs(app)
|
||
return
|
||
}
|
||
|
||
for _ in 0..<10 {
|
||
if hasMainTabs(app) { return }
|
||
let primaryButton = app.buttons["onboarding.primaryButton"]
|
||
if primaryButton.waitForExistence(timeout: 1) {
|
||
primaryButton.tap()
|
||
usleep(300_000)
|
||
continue
|
||
}
|
||
|
||
// Legacy fallback if accessibility identifiers are unavailable.
|
||
let fallbackPrimaryLabels = ["Get Started", "Next", "Commencer", "Suivant", "Comenzar", "Siguiente"]
|
||
for label in fallbackPrimaryLabels {
|
||
let button = app.buttons[label]
|
||
if button.exists && button.isHittable {
|
||
button.tap()
|
||
usleep(300_000)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func ensureKeepAwakeEnabled(_ app: XCUIApplication) {
|
||
openTab(.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(.alarms, in: app)
|
||
dismissAlarmErrorAlertIfPresent(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)
|
||
dismissAlarmErrorAlertIfPresent(in: app)
|
||
|
||
let cancelButton = app.buttons["alarms.add.cancelButton"]
|
||
if cancelButton.exists && cancelButton.isHittable {
|
||
cancelButton.tap()
|
||
sleep(1)
|
||
}
|
||
|
||
_ = firstSwitch.waitForExistence(timeout: 3)
|
||
}
|
||
|
||
// 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(_ tab: CaptureTab, in app: XCUIApplication) {
|
||
if isScreenVisible(identifier: tab.screenIdentifier, in: app) {
|
||
return
|
||
}
|
||
|
||
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 tapAndConfirm(_ tapAction: () -> Bool) -> Bool {
|
||
guard tapAction() else { return false }
|
||
return waitForScreen(tab, in: app, timeout: 1.5)
|
||
}
|
||
|
||
func tapLabeledButtonAnywhere() -> Bool {
|
||
let matches = app.buttons.matching(NSPredicate(format: "label IN %@", tab.labels))
|
||
.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 tapSymbolButtonAnywhere() -> Bool {
|
||
let matches = app.buttons.matching(NSPredicate(format: "label IN %@", tab.symbolLabels))
|
||
.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 IN %@", tab.labels))
|
||
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 tapSymbolButton() -> Bool {
|
||
let query = app.tabBars.buttons.matching(NSPredicate(format: "label IN %@", tab.symbolLabels))
|
||
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 {
|
||
let tabBar = app.tabBars.firstMatch
|
||
guard tabBar.exists else { return false }
|
||
let buttons = tabBar.buttons
|
||
guard buttons.count > tab.index else { return false }
|
||
let button = buttons.element(boundBy: tab.index)
|
||
guard button.exists else { return false }
|
||
button.tap()
|
||
return true
|
||
}
|
||
|
||
if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) {
|
||
return
|
||
}
|
||
|
||
// Fallback for cases where tab buttons are not exposed under tabBars.
|
||
if tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) {
|
||
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 tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) || tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) {
|
||
return
|
||
}
|
||
|
||
dismissInterferingPromptIfPresent(in: app)
|
||
if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) || tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) {
|
||
return
|
||
}
|
||
|
||
let buttonLabels = app.buttons.allElementsBoundByIndex
|
||
.prefix(16)
|
||
.map(\.label)
|
||
.joined(separator: ", ")
|
||
XCTFail("Could not open tab '\(tab)' by label/symbol/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 allButtons = app.buttons.allElementsBoundByIndex.filter(\.exists)
|
||
guard allButtons.count >= 2 else { return }
|
||
|
||
// Keep Awake prompt can obscure tabs in some locale/device combinations.
|
||
let candidate = allButtons[1]
|
||
if candidate.isHittable {
|
||
candidate.tap()
|
||
} else {
|
||
let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
||
.withOffset(CGVector(dx: candidate.frame.midX, dy: candidate.frame.midY))
|
||
coordinate.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)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func dismissAlarmErrorAlertIfPresent(in app: XCUIApplication) {
|
||
let alert = app.alerts.firstMatch
|
||
guard alert.waitForExistence(timeout: 1) else { return }
|
||
|
||
let dismissLabels = ["OK", "D’accord", "Aceptar", "Close", "Fermer", "Cerrar"]
|
||
for label in dismissLabels {
|
||
let button = alert.buttons[label]
|
||
if button.exists && button.isHittable {
|
||
button.tap()
|
||
usleep(250_000)
|
||
return
|
||
}
|
||
}
|
||
|
||
let fallback = alert.buttons.firstMatch
|
||
if fallback.exists {
|
||
fallback.tap()
|
||
usleep(250_000)
|
||
}
|
||
}
|
||
|
||
private func waitForScreen(_ tab: CaptureTab, in app: XCUIApplication, timeout: TimeInterval = 2) -> Bool {
|
||
let attempts = max(1, Int(timeout / 0.2))
|
||
for _ in 0..<attempts {
|
||
if isScreenVisible(identifier: tab.screenIdentifier, in: app) || isTabSelected(tab, in: app) {
|
||
return true
|
||
}
|
||
usleep(200_000)
|
||
}
|
||
return false
|
||
}
|
||
|
||
private func isScreenVisible(identifier: String, in app: XCUIApplication) -> Bool {
|
||
let match = app.descendants(matching: .any).matching(identifier: identifier).firstMatch
|
||
return match.exists
|
||
}
|
||
|
||
private func isTabSelected(_ tab: CaptureTab, in app: XCUIApplication) -> Bool {
|
||
let tabBar = app.tabBars.firstMatch
|
||
guard tabBar.exists else { return false }
|
||
let buttons = tabBar.buttons
|
||
guard buttons.count > tab.index else { return false }
|
||
|
||
let button = buttons.element(boundBy: tab.index)
|
||
if button.isSelected {
|
||
return true
|
||
}
|
||
|
||
let value = normalized((button.value as? String) ?? "")
|
||
return value == "1" || value.contains("selected")
|
||
}
|
||
|
||
private func hasMainTabs(_ app: XCUIApplication) -> Bool {
|
||
let labels = CaptureTab.allCases
|
||
.flatMap(\.labels)
|
||
.appending(contentsOf: CaptureTab.allCases.flatMap(\.symbolLabels))
|
||
if app.tabBars.count > 0 { return true }
|
||
return labels.contains(where: { app.buttons[$0].exists })
|
||
}
|
||
}
|
||
|
||
private extension Array {
|
||
func appending(contentsOf elements: [Element]) -> [Element] {
|
||
var copy = self
|
||
copy.append(contentsOf: elements)
|
||
return copy
|
||
}
|
||
}
|