diff --git a/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift index 8278d2d..701b034 100644 --- a/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift +++ b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift @@ -6,24 +6,20 @@ import UIKit import AppKit #endif -/// Captures App Store screenshots: launches the app once per staged board -/// (see `ScreenshotFixtures` in the app target) and captures the screen. -/// Run `fastlane screenshots` to drive this across every device size; images -/// land in `fastlane/screenshots/`. On iOS runs fastlane's `snapshot()` does -/// the capture; the macOS leg (which snapshot doesn't support) runs this same -/// test via xcodebuild and collects the attachment instead. +/// Captures one App Store screenshot per test so Fastlane can select exactly +/// the requested games with XCTest's `only-testing` support. final class ScreenshotCaptureUITests: XCTestCase { - /// One board per App Store screenshot, in store order. The catalog source - /// is compiled into both targets because UI tests cannot import the app. - private static let boards = ScreenshotFixtureCatalog.bundled.map(\.name) - - /// Appearance for every screenshot, pinned via UserDefaults launch - /// arguments so simulator state can't change the look between runs. - private static let appearance = [ - "-settings.tableBackgroundColor", "#5B9A9A", - "-settings.cardStyle", "classic", - "-settings.feltEffectEnabled", "YES" - ] + // Keep these method names aligned with GAME_TESTS in fastlane/Fastfile. + @MainActor func testScreenshot01KlondikeDraw3() throws { try capture("klondike-draw3") } + @MainActor func testScreenshot02Spider() throws { try capture("spider") } + @MainActor func testScreenshot03FreeCell() throws { try capture("freecell") } + @MainActor func testScreenshot04Yukon() throws { try capture("yukon") } + @MainActor func testScreenshot05Pyramid() throws { try capture("pyramid") } + @MainActor func testScreenshot06TriPeaks() throws { try capture("tripeaks") } + @MainActor func testScreenshot07Golf() throws { try capture("golf") } + @MainActor func testScreenshot08FortyThieves() throws { try capture("fortythieves") } + @MainActor func testScreenshot09Scorpion() throws { try capture("scorpion") } + @MainActor func testScreenshot10Canfield() throws { try capture("canfield") } #if os(macOS) /// Full window size in points — title bar and toolbar included, since @@ -31,58 +27,113 @@ final class ScreenshotCaptureUITests: XCTestCase { /// On a 2x display the capture comes out at 2880x1800 pixels — an exact /// Mac App Store screenshot size. private static let windowSize = CGSize(width: 1440, height: 900) + + /// XCTest creates a fresh test-case instance per method, so cache the + /// one window-frame probe across the selected macOS screenshots. + @MainActor private static var cachedMacContentSize: CGSize? #endif @MainActor - func testCaptureScreenshots() throws { -#if os(macOS) - // The app pins its *content* size (pure SwiftUI; the app target has - // no AppKit), but the capture needs the *window* to be exactly - // `windowSize`. Probe once to measure the title-bar height, then pin - // the content that much shorter for the real captures. - let titleBarHeight = try measureTitleBarHeight() - let contentSize = CGSize( - width: Self.windowSize.width, - height: Self.windowSize.height - titleBarHeight + private func capture(_ fixtureName: String) throws { + let fixture = try XCTUnwrap( + ScreenshotFixtureCatalog.fixture(named: fixtureName), + "unknown screenshot fixture: \(fixtureName)" ) + let app = XCUIApplication() +#if os(macOS) + let contentSize = try macContentSize() + // Ignore persisted window state so the app-side pin always wins. + app.launchArguments += [ + "-screenshotWindowSize", + "\(Int(contentSize.width))x\(Int(contentSize.height))", + "-ApplePersistenceIgnoreState", "YES" + ] +#else + // The explicit settle below is the only animation wait. Disabling the + // helper's extra delay and idle polling saves a second per screenshot. + setupSnapshot(app, waitForAnimations: false) + if UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + } #endif - for board in Self.boards { - let app = XCUIApplication() + let requestedCardStyle = Self.requestedCardStyle(in: app.launchArguments) + app.launchArguments += Self.appearance(cardStyle: requestedCardStyle) + app.launchArguments += Self.interfaceStyleArguments + app.launchArguments += ["-screenshotFixture", fixture.name] + app.launch() + XCTAssertTrue( + app.windows.firstMatch.waitForExistence(timeout: 10), + "\(fixture.name): app window never appeared" + ) + Thread.sleep(forTimeInterval: 2) + #if os(macOS) - // Ignore any persisted window state so the app-side pin always wins. - app.launchArguments += [ - "-screenshotWindowSize", - "\(Int(contentSize.width))x\(Int(contentSize.height))", - "-ApplePersistenceIgnoreState", "YES" - ] + try captureMacWindow(of: app, named: fixture.name) #else - setupSnapshot(app) - // iPad ships landscape App Store screenshots (solitaire is played - // landscape there); iPhone is portrait-only. - if UIDevice.current.userInterfaceIdiom == .pad { - XCUIDevice.shared.orientation = .landscapeLeft - } + snapshot(fixture.name, timeWaitingForIdle: 0) #endif - app.launchArguments += Self.appearance + ["-screenshotFixture", board] - app.launch() - XCTAssertTrue( - app.windows.firstMatch.waitForExistence(timeout: 10), - "\(board): app window never appeared" - ) - // Let load animations and the initial layout settle. - Thread.sleep(forTimeInterval: 2) + } + /// Pin every visible preference, while keeping the product's real platform + /// defaults: Simple cards on iOS/iPadOS and Classic cards on macOS. + private static func appearance(cardStyle requestedCardStyle: String?) -> [String] { + let defaultCardStyle: String #if os(macOS) - try captureMacWindow(of: app, named: board) + defaultCardStyle = "classic" #else - snapshot(board) + defaultCardStyle = "simple" +#endif + + return [ + "-settings.tableBackgroundColor", "#5B9A9A", + "-settings.cardStyle", requestedCardStyle ?? defaultCardStyle, + "-settings.cardBackColor", "navy", + "-settings.cardTiltEnabled", "YES", + "-settings.feltEffectEnabled", "YES" + ] + } + + /// iOS receives the option through Snapshot's app launch arguments; + /// macOS receives it through the xcodebuild test-runner environment. + private static func requestedCardStyle(in launchArguments: [String]) -> String? { +#if os(macOS) + if let environmentValue = ProcessInfo.processInfo.environment["SCREENSHOT_CARD_STYLE"], + !environmentValue.isEmpty { + return environmentValue + } #endif - // No explicit terminate: launch() relaunches a running app, and the - // session tears down the last instance. terminate() flakes on macOS. + guard let flagIndex = launchArguments.lastIndex(of: "-screenshotCardStyle") else { + return nil } + let valueIndex = launchArguments.index(after: flagIndex) + guard launchArguments.indices.contains(valueIndex) else { return nil } + return launchArguments[valueIndex] } + private static var interfaceStyleArguments: [String] { #if os(macOS) + let isDarkMode = ProcessInfo.processInfo.environment["SCREENSHOT_DARK_MODE"] != "false" + return ["-AppleInterfaceStyle", isDarkMode ? "Dark" : "Light"] +#else + return [] +#endif + } + +#if os(macOS) + @MainActor + private func macContentSize() throws -> CGSize { + if let cached = Self.cachedMacContentSize { + return cached + } + let titleBarHeight = try measureTitleBarHeight() + let size = CGSize( + width: Self.windowSize.width, + height: Self.windowSize.height - titleBarHeight + ) + Self.cachedMacContentSize = size + return size + } + /// Measures the window title-bar height: launches the app with its /// content pinned to the reference size and returns how much taller the /// window frame is. The probe instance is replaced by the next launch. @@ -93,8 +144,12 @@ final class ScreenshotCaptureUITests: XCTestCase { "-screenshotWindowSize", "\(Int(Self.windowSize.width))x\(Int(Self.windowSize.height))", "-ApplePersistenceIgnoreState", "YES", - "-screenshotFixture", Self.boards[0] + "-screenshotFixture", ScreenshotFixtureCatalog.top3[0].name ] + probe.launchArguments += Self.appearance( + cardStyle: Self.requestedCardStyle(in: probe.launchArguments) + ) + probe.launchArguments += Self.interfaceStyleArguments probe.launch() let window = probe.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 10), "probe window never appeared") diff --git a/Shared/ScreenshotFixtureCatalog.swift b/Shared/ScreenshotFixtureCatalog.swift index 77d30e5..d94ce2b 100644 --- a/Shared/ScreenshotFixtureCatalog.swift +++ b/Shared/ScreenshotFixtureCatalog.swift @@ -4,7 +4,6 @@ struct ScreenshotFixture: Identifiable, Hashable { let name: String /// Human-readable description of the staged board. let title: String - var id: String { name } } @@ -12,9 +11,9 @@ struct ScreenshotFixture: Identifiable, Hashable { enum ScreenshotFixtureCatalog { static let bundled: [ScreenshotFixture] = [ ScreenshotFixture(name: "klondike-draw3", title: "Klondike – Draw 3"), + ScreenshotFixture(name: "spider", title: "Spider – 2 suits"), ScreenshotFixture(name: "freecell", title: "FreeCell"), ScreenshotFixture(name: "yukon", title: "Yukon"), - ScreenshotFixture(name: "spider", title: "Spider – 2 suits"), ScreenshotFixture(name: "pyramid", title: "Pyramid"), ScreenshotFixture(name: "tripeaks", title: "TriPeaks"), ScreenshotFixture(name: "golf", title: "Golf"), @@ -22,4 +21,11 @@ enum ScreenshotFixtureCatalog { ScreenshotFixture(name: "scorpion", title: "Scorpion"), ScreenshotFixture(name: "canfield", title: "Canfield") ] + + /// The compact store set: the three most recognizable variants. + static let top3 = Array(bundled.prefix(3)) + + static func fixture(named name: String) -> ScreenshotFixture? { + bundled.first { $0.name == name } + } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8cde778..b41402e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,80 +1,274 @@ -# Screenshot automation. The full App Store set is one command: +# App Store screenshot automation. +# +# Fast default (one required iPhone + one required iPad, top three games): # # fastlane screenshots # -# iPhone/iPad come from `snapshot` (see Snapfile); the Mac window screenshot -# comes from a custom xcodebuild step because snapshot only supports -# iOS/tvOS/watchOS simulators. Staged boards live in the app as -# ScreenshotFixtures; see ScreenshotFixtureTests for regenerating them. +# Options: +# +# targets:iphone|ipad|ios|mac|all +# games:top3|all|klondike-draw3,spider,... +# devices:required|all +# reuse_build:true|false +# card_style:classic|simple|pixel (optional; platform default when omitted) +# dark_mode:true|false (default: true) default_platform(:ios) -# Mac shots land next to the iOS ones in the locale folder with a device-style -# prefix — the same layout `deliver` uploads from (it keys on pixel size). -# fastlane chdirs into this fastlane directory for lane execution, so plain -# Ruby file ops and `sh` both resolve these relative to fastlane/. -MAC_EXPORT_TMP = "screenshots/mac-export".freeze -MAC_FINAL_DIR = "screenshots/en-US".freeze - -# Shared scheme whose test action contains only ScreenshotCaptureUITests, so -# screenshot runs never build or run the (macOS-only) unit test suite. +FASTLANE_DIR = File.expand_path(__dir__).freeze +PROJECT_PATH = File.expand_path("../ComputerSolitaire.xcodeproj", FASTLANE_DIR).freeze +SCREENSHOTS_DIR = File.join(FASTLANE_DIR, "screenshots").freeze +LOCALE_DIR = File.join(SCREENSHOTS_DIR, "en-US").freeze +IOS_DERIVED_DATA = File.join(FASTLANE_DIR, "derived_data", "ios").freeze +MAC_DERIVED_DATA = File.join(FASTLANE_DIR, "derived_data", "mac").freeze +MAC_EXPORT_TMP = File.join(SCREENSHOTS_DIR, "mac-export").freeze +MAC_RESULT_BUNDLE = File.join(SCREENSHOTS_DIR, "mac.xcresult").freeze SCHEME = "ComputerSolitaireScreenshots".freeze +GAME_TESTS = { + "klondike-draw3" => "testScreenshot01KlondikeDraw3", + "spider" => "testScreenshot02Spider", + "freecell" => "testScreenshot03FreeCell", + "yukon" => "testScreenshot04Yukon", + "pyramid" => "testScreenshot05Pyramid", + "tripeaks" => "testScreenshot06TriPeaks", + "golf" => "testScreenshot07Golf", + "fortythieves" => "testScreenshot08FortyThieves", + "scorpion" => "testScreenshot09Scorpion", + "canfield" => "testScreenshot10Canfield" +}.freeze +TOP3_GAMES = %w[klondike-draw3 spider freecell].freeze + +REQUIRED_DEVICES = { + iphone: ["iPhone 17 Pro Max"], + ipad: ["iPad Pro 13-inch (M5)"] +}.freeze +ALL_DEVICES = { + iphone: ["iPhone 17 Pro Max", "iPhone 17 Pro"], + ipad: ["iPad Pro 13-inch (M5)", "iPad Pro 11-inch (M5)"] +}.freeze + platform :ios do - desc "Capture all App Store screenshots (iPhone, iPad, and Mac)" - lane :screenshots do - # One iOS build shared by every simulator run (snapshot is configured - # with test_without_building against this derived data). - run_tests( - project: "ComputerSolitaire.xcodeproj", + desc "Capture selected App Store screenshots (defaults: iOS, top 3, required devices)" + lane :screenshots do |options| + configuration = screenshot_configuration(options) + selected_tests = test_identifiers(configuration[:games]) + + UI.message( + "Screenshots: targets=#{configuration[:targets]}, " \ + "games=#{configuration[:games].join(',')}, " \ + "devices=#{configuration[:device_scope]}, " \ + "card_style=#{configuration[:card_style] || 'default'}, " \ + "dark_mode=#{configuration[:dark_mode]}, " \ + "reuse_build=#{configuration[:reuse_build]}" + ) + validate_reusable_builds(configuration) if configuration[:reuse_build] + clear_screenshot_output + + unless configuration[:ios_devices].empty? + run_ios_screenshot_capture( + devices: configuration[:ios_devices], + selected_tests: selected_tests, + card_style: configuration[:card_style], + dark_mode: configuration[:dark_mode], + reuse_build: configuration[:reuse_build] + ) + end + + if configuration[:include_mac] + run_mac_screenshot_capture( + selected_tests: selected_tests, + card_style: configuration[:card_style], + dark_mode: configuration[:dark_mode], + reuse_build: configuration[:reuse_build] + ) + end + + UI.success("Screenshots saved to #{LOCALE_DIR}") + end + + private_lane :run_ios_screenshot_capture do |options| + unless options[:reuse_build] + run_tests( + project: PROJECT_PATH, + scheme: SCHEME, + destination: "generic/platform=iOS Simulator", + derived_data_path: IOS_DERIVED_DATA, + build_for_testing: true, + only_testing: options[:selected_tests] + ) + end + require_test_build!(IOS_DERIVED_DATA, "iOS") + + snapshot( + project: PROJECT_PATH, scheme: SCHEME, - destination: "generic/platform=iOS Simulator", - derived_data_path: "fastlane/derived_data", - build_for_testing: true + devices: options[:devices], + languages: ["en-US"], + output_directory: SCREENSHOTS_DIR, + derived_data_path: IOS_DERIVED_DATA, + test_without_building: true, + only_testing: options[:selected_tests], + launch_arguments: options[:card_style] ? ["-screenshotCardStyle #{options[:card_style]}"] : [""], + dark_mode: options[:dark_mode], + clear_previous_screenshots: false, + skip_package_dependencies_resolution: true, + headless: true, + skip_open_summary: true, + number_of_retries: 0, + stop_after_first_error: true ) - snapshot - mac_screenshots end - desc "Capture the Mac window screenshot via xcodebuild" - private_lane :mac_screenshots do - # The UI test can't terminate an app instance it didn't spawn (e.g. one - # left running from Xcode), so quit it first — gracefully, then harder. - # Gated on the app actually running: an unconditional quit AppleEvent can - # linger and get delivered to the instance the test launches later, - # quitting it mid-capture. An instance *paused in Xcode's debugger* can't - # be killed at all (the kernel defers signals for traced processes); if - # this leg fails with "Failed to terminate", hit Stop in Xcode and rerun. + # Selecting targets:mac or targets:all is the explicit authorization for + # this visible capture. macOS is never included by default. + private_lane :run_mac_screenshot_capture do |options| + unless options[:reuse_build] + sh( + "xcodebuild", "build-for-testing", + "-project", PROJECT_PATH, + "-scheme", SCHEME, + "-destination", "platform=macOS", + "-derivedDataPath", MAC_DERIVED_DATA, + "-quiet" + ) + end + require_test_build!(MAC_DERIVED_DATA, "macOS") + + # A previously launched copy prevents the UI test from owning the window. if system("pgrep -xq 'Computer Solitaire'") sh(%q(osascript -e 'with timeout of 3 seconds' -e 'quit app "Computer Solitaire"' -e 'end timeout' 2>/dev/null || true)) sh(%q(pkill -x "Computer Solitaire" 2>/dev/null || true; sleep 1; pkill -9 -x "Computer Solitaire" 2>/dev/null || true)) end - result_bundle = "screenshots/mac.xcresult" - FileUtils.rm_rf([MAC_EXPORT_TMP, result_bundle]) - FileUtils.mkdir_p([MAC_EXPORT_TMP, MAC_FINAL_DIR]) + FileUtils.rm_rf([MAC_EXPORT_TMP, MAC_RESULT_BUNDLE]) + FileUtils.mkdir_p([MAC_EXPORT_TMP, LOCALE_DIR]) - sh("xcodebuild", "test", - "-project", "../ComputerSolitaire.xcodeproj", - "-scheme", SCHEME, - "-destination", "platform=macOS", - "-resultBundlePath", result_bundle, - "-quiet") - sh("xcrun", "xcresulttool", "export", "attachments", - "--path", result_bundle, "--output-path", MAC_EXPORT_TMP) + test_command = [ + "env", + "TEST_RUNNER_SCREENSHOT_CARD_STYLE=#{options[:card_style]}", + "TEST_RUNNER_SCREENSHOT_DARK_MODE=#{options[:dark_mode]}", + "xcodebuild", "test-without-building", + "-project", PROJECT_PATH, + "-scheme", SCHEME, + "-destination", "platform=macOS", + "-derivedDataPath", MAC_DERIVED_DATA, + "-resultBundlePath", MAC_RESULT_BUNDLE, + "-quiet" + ] + options[:selected_tests].each do |test| + test_command << "-only-testing:#{test}" + end + sh(*test_command) + sh( + "xcrun", "xcresulttool", "export", "attachments", + "--path", MAC_RESULT_BUNDLE, + "--output-path", MAC_EXPORT_TMP + ) rename_exported_attachments(MAC_EXPORT_TMP) Dir.glob(File.join(MAC_EXPORT_TMP, "*.png")).each do |png| - FileUtils.mv(png, File.join(MAC_FINAL_DIR, "macOS-#{File.basename(png)}")) + FileUtils.mv(png, File.join(LOCALE_DIR, "macOS-#{File.basename(png)}")) end - FileUtils.rm_rf([MAC_EXPORT_TMP, result_bundle]) - UI.success("Mac screenshots in fastlane/#{MAC_FINAL_DIR}") + FileUtils.rm_rf([MAC_EXPORT_TMP, MAC_RESULT_BUNDLE]) + end +end + +def screenshot_configuration(options) + targets = (options[:targets] || "ios").to_s.downcase + unless %w[iphone ipad ios mac all].include?(targets) + UI.user_error!("targets must be one of: iphone, ipad, ios, mac, all") + end + + device_scope = (options[:devices] || "required").to_s.downcase + unless %w[required all].include?(device_scope) + UI.user_error!("devices must be either required or all") end + + device_catalog = device_scope == "all" ? ALL_DEVICES : REQUIRED_DEVICES + ios_devices = [] + ios_devices.concat(device_catalog[:iphone]) if %w[iphone ios all].include?(targets) + ios_devices.concat(device_catalog[:ipad]) if %w[ipad ios all].include?(targets) + + { + targets: targets, + games: parse_games(options[:games] || "top3"), + device_scope: device_scope, + reuse_build: parse_boolean(options[:reuse_build], default: false), + card_style: parse_card_style(options[:card_style]), + dark_mode: parse_boolean(options[:dark_mode], default: true), + ios_devices: ios_devices, + include_mac: %w[mac all].include?(targets) + } +end + +def parse_card_style(value) + return nil if value.nil? || value.to_s.empty? + + card_style = value.to_s.downcase + unless %w[classic simple pixel].include?(card_style) + UI.user_error!("card_style must be one of: classic, simple, pixel") + end + card_style +end + +def parse_games(value) + selection = value.to_s.downcase + games = case selection + when "top3" then TOP3_GAMES + when "all" then GAME_TESTS.keys + else selection.split(",").map(&:strip).reject(&:empty?) + end + UI.user_error!("games must not be empty") if games.empty? + + unknown = games.uniq - GAME_TESTS.keys + unless unknown.empty? + UI.user_error!( + "unknown games: #{unknown.join(', ')}. Valid names: #{GAME_TESTS.keys.join(', ')}" + ) + end + games.uniq +end + +def parse_boolean(value, default:) + return default if value.nil? + return value if value == true || value == false + + case value.to_s.downcase + when "true" then true + when "false" then false + else UI.user_error!("reuse_build must be true or false") + end +end + +def test_identifiers(games) + games.map do |game| + "ComputerSolitaireUITests/ScreenshotCaptureUITests/#{GAME_TESTS.fetch(game)}" + end +end + +def require_test_build!(derived_data_path, platform_name) + builds = Dir.glob(File.join(derived_data_path, "Build", "Products", "*.xctestrun")) + return unless builds.empty? + + UI.user_error!( + "No reusable #{platform_name} screenshot build exists at #{derived_data_path}. " \ + "Run once with reuse_build:false." + ) +end + +def validate_reusable_builds(configuration) + require_test_build!(IOS_DERIVED_DATA, "iOS") unless configuration[:ios_devices].empty? + require_test_build!(MAC_DERIVED_DATA, "macOS") if configuration[:include_mac] +end + +def clear_screenshot_output + FileUtils.rm_rf(LOCALE_DIR) + FileUtils.mkdir_p(LOCALE_DIR) end -# xcresulttool exports opaque file names; the manifest maps them back to the -# attachment names the UI test set (board names, plus an index/UUID suffix -# this strips). Non-PNG attachments are runner diagnostics — dropped. +# xcresulttool exports opaque names. Restore the attachment names assigned by +# ScreenshotCaptureUITests and discard non-PNG runner diagnostics. def rename_exported_attachments(export_dir) manifest_path = File.join(export_dir, "manifest.json") return unless File.exist?(manifest_path) diff --git a/fastlane/README.md b/fastlane/README.md index 256c1fc..d6bb1ef 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -21,7 +21,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do [bundle exec] fastlane ios screenshots ``` -Capture all App Store screenshots (iPhone, iPad, and Mac) +Capture selected App Store screenshots (defaults: iOS, top 3, required devices) ---- diff --git a/fastlane/Snapfile b/fastlane/Snapfile index 0c441bc..6a4b0bb 100644 --- a/fastlane/Snapfile +++ b/fastlane/Snapfile @@ -1,32 +1,15 @@ -# Configuration for `fastlane snapshot` — iPhone/iPad App Store screenshots. -# The Mac screenshot is captured by the custom step in the :screenshots lane -# (snapshot does not support macOS apps). +# Stable iOS capture behavior. Devices, games, and paths are supplied by the +# `screenshots` lane because they depend on its runtime options. -# Shared scheme whose test action contains only ScreenshotCaptureUITests, so -# device runs never build or run the (macOS-only) unit test suite. scheme("ComputerSolitaireScreenshots") - -devices([ - "iPhone 17 Pro Max", # 6.9" (1320x2868) — the required iPhone size - "iPhone 17 Pro", # 6.3" (1206x2622) - "iPad Pro 13-inch (M5)", # 13" (2064x2752) — the required iPad size - "iPad Pro 11-inch (M5)" # 11" (2420x1668) -]) - languages(["en-US"]) -# Clean 9:41 / full-bars / full-battery status bar, applied by fastlane. override_status_bar(true) - -# Concurrent mode runs on simulator clones, which don't inherit status-bar -# overrides — keep runs sequential. concurrent_simulators(false) - -output_directory("./fastlane/screenshots") -clear_previous_screenshots(true) +headless(true) +dark_mode(true) +skip_open_summary(true) +clear_previous_screenshots(false) +number_of_retries(0) stop_after_first_error(true) - -# The lane builds once via build_for_testing; every device run reuses that -# build instead of recompiling the app per device. -derived_data_path("./fastlane/derived_data") test_without_building(true)