TheNoiseClock/TheNoiseClockUITests/TheNoiseClockUITests.swift

539 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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", "Daccord", "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
}
}