diff --git a/CLAUDE.md b/CLAUDE.md index 6bbab91..1188fc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,37 +113,61 @@ PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is: - **ShareSheet** triggered via `@State` item binding in the parent view. - **Toolbar button** (not inline content) for the export action. -## Screenshots & Previews +## Screenshots -Screenshot previews for all major views live in `Cable/ScreenshotPreviews.swift`. This file contains wrapper views and realistic sample data so screenshots include full app chrome (NavigationBar, TabBar). +App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`. -### How to render screenshots +### Running screenshots -1. Use `mcp__xcode__RenderPreview` with `sourceFilePath: "Cable/ScreenshotPreviews.swift"` and `previewDefinitionIndexInFile` (0–44, grouped by view × 5 languages). -2. Output goes to `Shots/Screenshots/`. +```bash +./shooter.sh # reads ./screenshot.config +./shooter.sh other.config # explicit config file +VERBOSE=1 ./shooter.sh # full logs on failure +PARALLEL=0 ./shooter.sh # sequential mode +``` -### Preview index mapping +Requires `xcparse`: `brew install chargepoint/xcparse/xcparse`. -Previews are grouped in blocks of 5 (EN, DE, ES, FR, NL): -- 0–4: Overview tab -- 5–9: Components tab -- 10–14: Batteries tab -- 15–19: Chargers tab -- 20–24: Systems list -- 25–29: Parts Library -- 30–34: Load editor (CalculatorView) -- 35–39: Battery editor -- 40–44: Charger editor +### How it works -### Key patterns for preview-friendly views +1. `shooter.sh` reads `screenshot.config` for scheme, bundle ID, devices, and languages. +2. Builds once with `build-for-testing`, then runs `test-without-building` per language. +3. Per language run: erases simulator, sets locale via `simctl spawn ... defaults write`, suppresses system notifications (DND, Apple Intelligence), overrides status bar (9:41, full battery). +4. `xcparse` extracts screenshot attachments from `.xcresult` bundles into `Shots/Screenshots/{device-slug}/{lang}/`. +5. Devices run in parallel (languages sequential per device — same simulator). -- **LoadsView** accepts an `initialTab` parameter (`LoadsView.ComponentTab`) to control which tab is shown. Wrap in `NavigationStack` to get the navigation bar. -- **ComponentLibraryView** accepts an optional `viewModel` parameter via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to inject mock data and avoid network dependency. -- All preview wrappers inject `.modelContainer(container)` (in-memory) and `.environmentObject(UnitSystemSettings())`. +### Test structure -### Localization limitation +- **Test target**: `CableUITestsScreenshot` (scheme: `CableScreenshots`, test plan: `CableScreenshots.xctestplan`) +- **Test file**: `CableUITestsScreenshot/CableUITestsScreenshot.swift` +- **Sample data**: `UITestSampleData.swift` — seeded via `--uitest-sample-data` launch argument, cleared via `--uitest-reset-data` -`.environment(\.locale, Locale(identifier: "xx"))` does **not** affect `String(localized:defaultValue:)` — those resolve from the app bundle, not the SwiftUI environment. All preview screenshots render in the system language. For multi-language screenshots, use UI tests with `-AppleLanguages` launch arguments or change the Xcode scheme language. +### Screenshot inventory + +| # | Name | Source | +|---|------|--------| +| 01 | OnboardingSystemsView | Empty state after reset | +| 02 | OnboardingSystemView | New system overview | +| 03 | LoadEditorView | CalculatorView for new load | +| 04 | ComponentSelectorView | Component library (network) | +| 05 | SystemsWithSampleData | Systems list with sample data | +| 06 | AdventureVanOverview | Overview tab | +| 07 | AdventureVanLoads | Components tab | +| 08 | BillOfMaterials | System BOM sheet | +| 09 | AdventureVanCalculator | Load calculator | +| 10 | AdventureVanBatteries | Batteries tab | +| 11 | BatteryEditor | Battery editor | +| 12 | AdventureVanChargers | Chargers tab | +| 13 | ChargerEditor | Charger editor | + +### Accessibility identifiers for UI tests + +Key identifiers used by the screenshot tests: `create-system-button`, `systems-list`, `system-overview`, `overview-tab`, `components-tab`, `batteries-tab`, `chargers-tab`, `loads-list`, `batteries-list`, `chargers-list`, `system-bom-button`, `system-bom-close-button`, `library-view-close-button`, `create-component-button`, `select-component-button`. + +### Preview-friendly view inits + +- **LoadsView** accepts `initialTab` (`LoadsView.ComponentTab`) to control which tab is shown. +- **ComponentLibraryView** accepts an optional `viewModel` via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to bypass network. ## Model Definitions diff --git a/Cable/Batteries/BatteriesView.swift b/Cable/Batteries/BatteriesView.swift index 64223d4..9f8847b 100644 --- a/Cable/Batteries/BatteriesView.swift +++ b/Cable/Batteries/BatteriesView.swift @@ -249,6 +249,7 @@ struct BatteriesView: View { .listStyle(.plain) .scrollContentBackground(.hidden) .environment(\.editMode, $editMode) + .accessibilityIdentifier("batteries-list") } private func batteryRow(for battery: SavedBattery) -> some View { diff --git a/CableUITestsScreenshot/CableUITestsScreenshot.swift b/CableUITestsScreenshot/CableUITestsScreenshot.swift index 5198804..7d87c63 100644 --- a/CableUITestsScreenshot/CableUITestsScreenshot.swift +++ b/CableUITestsScreenshot/CableUITestsScreenshot.swift @@ -125,32 +125,21 @@ final class CableUITestsScreenshot: XCTestCase { try super.setUpWithError() continueAfterFailure = false XCUIDevice.shared.orientation = .portrait - //ensureDoNotDisturbEnabled() - //dismissSystemOverlays() } - override func tearDownWithError() throws { - try super.tearDownWithError() - //dismissSystemOverlays() - } + // MARK: - Onboarding Screenshots @MainActor func testOnboardingScreenshots() throws { let app = launchApp(arguments: ["--uitest-reset-data"]) - waitForStability(long: true) + // Wait for Apple Intelligence and other system notifications to appear, then dismiss + RunLoop.current.run(until: Date().addingTimeInterval(6)) dismissNotificationBannersIfNeeded() - waitForStability(long: true) - dismissNotificationBannersIfNeeded() - waitForStability(long: true) - dismissNotificationBannersIfNeeded() - waitForStability(long: true) - dismissNotificationBannersIfNeeded() - + let createSystemButton = app.buttons["create-system-button"] XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8)) - waitForStability(long: true) dismissNotificationBannersIfNeeded() - waitForStability(long: true) + waitForStability() takeScreenshot(named: "01-OnboardingSystemsView") createSystemButton.tap() @@ -159,14 +148,15 @@ final class CableUITestsScreenshot: XCTestCase { XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8)) let browseLibraryButton = button(in: app.buttons, for: .browseLibrary) XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4)) - - waitForStability() + dismissNotificationBannersIfNeeded() takeScreenshot(named: "02-OnboardingSystemView") browseLibraryButton.tap() let libraryCloseButton = app.buttons["library-view-close-button"] XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8)) - waitForStability(long: true) + // Wait for library items AND remote icons to load from PocketBase + RunLoop.current.run(until: Date().addingTimeInterval(15)) + dismissNotificationBannersIfNeeded() takeScreenshot(named: "04-ComponentSelectorView") libraryCloseButton.tap() @@ -175,20 +165,130 @@ final class CableUITestsScreenshot: XCTestCase { let newLoadButton = button(in: app.buttons, for: .defaultLoadName) XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8)) - waitForStability(long: true) + waitForStability() + dismissNotificationBannersIfNeeded() takeScreenshot(named: "03-LoadEditorView") } + // MARK: - Sample Data Screenshots + @MainActor func testSampleDataScreenshots() throws { + let app = launchAppWithSampleData() + dismissNotificationBannersIfNeeded() + + // Systems list + let systemsList = resolvedSystemsList(in: app) + XCTAssertTrue(systemsList.waitForExistence(timeout: 4)) + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "05-SystemsWithSampleData") + + // Navigate to first system + openFirstSystem(in: app, systemsList: systemsList) + + // Overview tab — wait for navigation animation to complete + waitForStability() + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "06-AdventureVanOverview") + + // Bill of Materials + let bomElement = resolveBillOfMaterialsElement(in: app) + if !bomElement.waitForExistence(timeout: 6) { + bringElementIntoView(bomElement, in: app) + } + XCTAssertTrue(bomElement.exists) + if !bomElement.isHittable { + bringElementIntoView(bomElement, in: app, requireHittable: true) + } + tapElement(bomElement) + waitForStability() + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "08-BillOfMaterials") + + let closeButton = app.buttons["system-bom-close-button"] + XCTAssertTrue(closeButton.waitForExistence(timeout: 6)) + closeButton.tap() + + // Components tab + tapTab(.componentsTab, in: app) + let loadsList = resolvedLoadsList(in: app) + XCTAssertTrue(loadsList.waitForExistence(timeout: 4)) + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "07-AdventureVanLoads") + + // Open first load → Calculator + let firstLoad = loadsList.cells.element(boundBy: 0) + XCTAssertTrue(firstLoad.waitForExistence(timeout: 2)) + firstLoad.tap() + waitForStability() + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "09-AdventureVanCalculator") + + // Navigate back to system tabs + app.navigationBars.buttons.element(boundBy: 0).tap() + waitForStability() + + // Batteries tab + tapTab(.batteriesTab, in: app) + waitForStability() + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "10-AdventureVanBatteries") + + // Open first battery → Battery Editor + let batteriesList = resolvedBatteriesList(in: app) + if batteriesList.waitForExistence(timeout: 4) { + let firstBattery = batteriesList.cells.element(boundBy: 0) + if firstBattery.waitForExistence(timeout: 2) { + firstBattery.tap() + waitForStability() + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "11-BatteryEditor") + // Navigate back + app.navigationBars.buttons.element(boundBy: 0).tap() + waitForStability() + } + } + + // Chargers tab + tapTab(.chargersTab, in: app) + waitForStability() + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "12-AdventureVanChargers") + + // Open first charger → Charger Editor + let chargersList = resolvedChargersList(in: app) + if chargersList.waitForExistence(timeout: 4) { + let firstCharger = chargersList.cells.element(boundBy: 0) + if firstCharger.waitForExistence(timeout: 2) { + firstCharger.tap() + waitForStability() + dismissNotificationBannersIfNeeded() + takeScreenshot(named: "13-ChargerEditor") + } + } + } + + // MARK: - App Launch + + private func launchApp(arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + var launchArguments = ["--uitest-reset-data"] + launchArguments.append(contentsOf: arguments) + app.launchArguments = launchArguments + app.launch() + return app + } + + private func launchAppWithSampleData() -> XCUIApplication { let app = XCUIApplication() app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"] app.launch() + return app + } - let systemsList = resolvedSystemsList(in: app) - XCTAssertTrue(systemsList.waitForExistence(timeout: 4)) - takeScreenshot(named: "05-SystemsWithSampleData") + // MARK: - Navigation Helpers + private func openFirstSystem(in app: XCUIApplication, systemsList: XCUIElement) { let firstSystemCell = systemsList.cells.element(boundBy: 0) XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2)) let systemName = firstSystemCell.staticTexts.firstMatch.label @@ -206,69 +306,41 @@ final class CableUITestsScreenshot: XCTestCase { detailVisible = waitForSystemDetail(named: systemName, in: app) } XCTAssertTrue(detailVisible) - takeScreenshot(named: "06-AdventureVanOverview") - -// let overviewTab = app.buttons["overview-tab"] -// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3)) -// overviewTab.tap() - waitForStability(long: false) - let bomElement = resolveBillOfMaterialsElement(in: app) - - if !bomElement.waitForExistence(timeout: 6) { - bringElementIntoView(bomElement, in: app) - } - - XCTAssertTrue(bomElement.exists) - - if !bomElement.isHittable { - bringElementIntoView(bomElement, in: app, requireHittable: true) - } - - if bomElement.isHittable { - bomElement.tap() - } else { - bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - } - waitForStability(long: true) - takeScreenshot(named: "08-BillOfMaterials") - - let closeButton = app.buttons["system-bom-close-button"] - XCTAssertTrue(closeButton.waitForExistence(timeout: 6)) - closeButton.tap() - - let componentsTab = componentsTabButton(in: app) - XCTAssertTrue(componentsTab.waitForExistence(timeout: 3)) - if componentsTab.isHittable { - componentsTab.tap() - } else { - componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - } - - let loadsList = resolvedLoadsList(in: app) - XCTAssertTrue(loadsList.waitForExistence(timeout: 4)) - takeScreenshot(named: "07-AdventureVanLoads") - - waitForStability() - let firstLoad = loadsList.cells.element(boundBy: 0) - XCTAssertTrue(firstLoad.waitForExistence(timeout: 2)) - let loadName = firstLoad.staticTexts.firstMatch.label - firstLoad.tap() - - let loadNavButton = app.navigationBars.buttons[loadName] - XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3)) - takeScreenshot(named: "09-AdventureVanCalculator") } - private func launchApp(arguments: [String]) -> XCUIApplication { - let app = XCUIApplication() - var launchArguments = ["--uitest-reset-data"] - launchArguments.append(contentsOf: arguments) - app.launchArguments = launchArguments - app.launch() - //dismissSystemOverlays() - return app + private func tapTab(_ key: UIStringKey, in app: XCUIApplication) { + let identifierMap: [UIStringKey: String] = [ + .overviewTab: "overview-tab", + .componentsTab: "components-tab", + .batteriesTab: "batteries-tab", + .chargersTab: "chargers-tab", + ] + + // Use .matching + .firstMatch to avoid "multiple matches" error + // with iOS 26 floating tab bar (which creates duplicate elements) + if let identifier = identifierMap[key] { + let tabButton = app.buttons.matching(identifier: identifier).firstMatch + if tabButton.waitForExistence(timeout: 3) { + tapElement(tabButton) + return + } + } + + let tabButton = button(in: app.buttons, for: key) + XCTAssertTrue(tabButton.waitForExistence(timeout: 3)) + tapElement(tabButton) } + private func tapElement(_ element: XCUIElement) { + if element.isHittable { + element.tap() + } else { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + } + + // MARK: - Element Resolution + private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement { let collection = app.collectionViews["systems-list"] if collection.waitForExistence(timeout: 6) { @@ -299,6 +371,62 @@ final class CableUITestsScreenshot: XCTestCase { return table } + private func resolvedBatteriesList(in app: XCUIApplication) -> XCUIElement { + let collection = app.collectionViews["batteries-list"] + if collection.waitForExistence(timeout: 6) { + return collection + } + + let table = app.tables["batteries-list"] + if table.waitForExistence(timeout: 6) { + return table + } + + // Fallback: any list on screen + if app.collectionViews.firstMatch.waitForExistence(timeout: 2) { + return app.collectionViews.firstMatch + } + return app.tables.firstMatch + } + + private func resolvedChargersList(in app: XCUIApplication) -> XCUIElement { + let collection = app.collectionViews["chargers-list"] + if collection.waitForExistence(timeout: 6) { + return collection + } + + let table = app.tables["chargers-list"] + if table.waitForExistence(timeout: 6) { + return table + } + + if app.collectionViews.firstMatch.waitForExistence(timeout: 2) { + return app.collectionViews.firstMatch + } + return app.tables.firstMatch + } + + private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement { + let identifier = "system-bom-button" + let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch + if buttonByIdentifier.exists { return buttonByIdentifier } + + let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch + if elementByIdentifier.exists { return elementByIdentifier } + + let candidates = candidateStrings(for: .billOfMaterials) + for candidate in candidates { + let button = app.buttons[candidate] + if button.exists { return button } + let other = app.otherElements[candidate] + if other.exists { return other } + } + + return buttonByIdentifier + } + + // MARK: - Screenshots & Stability + private func takeScreenshot(named name: String) { let screenshot = XCUIScreen.main.screenshot() let attachment = XCTAttachment(screenshot: screenshot) @@ -308,52 +436,7 @@ final class CableUITestsScreenshot: XCTestCase { } private func waitForStability(long: Bool = false) { - RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5)) - } - - private func componentsTabButton(in app: XCUIApplication) -> XCUIElement { - let identifierMatch = app.descendants(matching: .any) - .matching(identifier: "components-tab").firstMatch - if identifierMatch.exists { - return identifierMatch - } - - let localizedLabels = [ - "Components", "Verbraucher", "Componentes", "Composants", "Componenten" - ] - for label in localizedLabels { - let button = app.buttons[label] - if button.exists { - return button - } - - let tabBarButton = app.tabBars.buttons[label] - if tabBarButton.exists { - return tabBarButton - } - - let segmentedButton = app.segmentedControls.buttons[label] - if segmentedButton.exists { - return segmentedButton - } - - let segmentedOther = app.segmentedControls.otherElements[label] - if segmentedOther.exists { - return segmentedOther - } - } - - let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1) - if fallbackSegmented.exists { - return fallbackSegmented - } - - let tabBarButton = app.tabBars.buttons.element(boundBy: 1) - if tabBarButton.exists { - return tabBarButton - } - - return app.tabBars.descendants(matching: .any).firstMatch + RunLoop.current.run(until: Date().addingTimeInterval(long ? 3.0 : 0.5)) } private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool { @@ -362,15 +445,12 @@ final class CableUITestsScreenshot: XCTestCase { if app.otherElements["system-overview"].exists { return true } - let navBar = app.navigationBars.firstMatch if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) } - return app.otherElements["system-overview"].exists } @@ -395,32 +475,13 @@ final class CableUITestsScreenshot: XCTestCase { } } - private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement { - let identifier = "system-bom-button" - let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch - if buttonByIdentifier.exists { return buttonByIdentifier } - - let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch - if elementByIdentifier.exists { return elementByIdentifier } - - let candidates = candidateStrings(for: .billOfMaterials) - for candidate in candidates { - let button = app.buttons[candidate] - if button.exists { - return button - } - let other = app.otherElements[candidate] - if other.exists { - return other - } - } - - return buttonByIdentifier - } + // MARK: - Notification Dismissal private func dismissNotificationBannersIfNeeded() { - let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch - if banner.waitForExistence(timeout: 1) { + // Try multiple times — notifications can appear with a delay + for _ in 0..<3 { + let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch + guard banner.waitForExistence(timeout: 1) else { return } if banner.isHittable { banner.swipeUp() } else { @@ -432,6 +493,8 @@ final class CableUITestsScreenshot: XCTestCase { } } + // MARK: - Localized Element Matching + private func candidateStrings(for key: UIStringKey) -> [String] { var values = Set() if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }), @@ -444,9 +507,6 @@ final class CableUITestsScreenshot: XCTestCase { if let others = translations[key]?.values { values.formUnion(others) } - if key == .settings { - values.insert("gearshape") - } return Array(values) } @@ -465,139 +525,4 @@ final class CableUITestsScreenshot: XCTestCase { ) return query.matching(predicate).firstMatch } - - private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? { - let element = button(in: query, for: key) - return element.exists ? element : nil - } - - private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement { - let tabSpecific = button(in: app.tabBars.buttons, for: key) - if tabSpecific.exists { - return tabSpecific - } - return button(in: app.buttons, for: key) - } - - private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement { - let candidates = candidateStrings(for: key) - for candidate in candidates { - let bar = app.navigationBars[candidate] - if bar.exists { - return bar - } - } - return app.navigationBars.element(boundBy: 0) - } - - private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) { - let candidates = candidateStrings(for: key) - for candidate in candidates { - let button = app.buttons[candidate] - if button.waitForExistence(timeout: 2) { - button.tap() - return - } - } - } - - private func openBillOfMaterials(app: XCUIApplication) { - let bomButton = button(in: app.buttons, for: .billOfMaterials) - XCTAssertTrue(bomButton.waitForExistence(timeout: 6)) - bomButton.tap() - let bomView = app.otherElements["system-bom-view"] - XCTAssertTrue(bomView.waitForExistence(timeout: 8)) - waitForStability(long: true) - } - - private func closeBillOfMaterials(app: XCUIApplication) { - tapButtonIfPresent(app: app, key: .close) - } - - private func navigateBack(app: XCUIApplication) { - let backButton = app.navigationBars.buttons.element(boundBy: 0) - if backButton.exists { - backButton.tap() - } else { - app.swipeRight() - } - } - - private func openSettings(app: XCUIApplication) { - let systemsBar = navigationBar(in: app, key: .systemsTitle) - let settingsButton = button(in: systemsBar.buttons, for: .settings) - if settingsButton.exists { - settingsButton.tap() - } else { - systemsBar.buttons.element(boundBy: 0).tap() - } - } - - private func ensureDoNotDisturbEnabled() { - springboard.activate() - let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02)) - let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30)) - pullStart.press(forDuration: 0.1, thenDragTo: pullEnd) - - let focusTile = springboard.otherElements["Focus"] - let focusButton = springboard.buttons["Focus"] - if focusTile.waitForExistence(timeout: 2) { - focusTile.press(forDuration: 1.0) - } else if focusButton.waitForExistence(timeout: 2) { - focusButton.press(forDuration: 1.0) - } else { - return - } - - let dndButton = springboard.buttons["Do Not Disturb"] - if dndButton.waitForExistence(timeout: 1) { - if !dndButton.isSelected { - dndButton.tap() - } - } else { - let dndCell = springboard.cells["Do Not Disturb"] - if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected { - dndCell.tap() - } else { - let dndLabel = springboard.staticTexts["Do Not Disturb"] - if dndLabel.waitForExistence(timeout: 1) { - dndLabel.tap() - } - } - } - - let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) - let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) - dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd) - } - - private func dismissSystemOverlays() { - let app = XCUIApplication() - let alertButtons = [ - "OK", "Allow", "Later", "Not Now", "Close", - "Continue", "Remind Me Later", "Maybe Later", - ] - - if app.alerts.firstMatch.exists { - handleAlerts(in: app, buttons: alertButtons) - } - - if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists { - handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"]) - } - } - - private func handleAlerts(in application: XCUIApplication, buttons: [String]) { - for buttonLabel in buttons { - let button = application.buttons[buttonLabel] - if button.waitForExistence(timeout: 0.5) { - button.tap() - return - } - } - let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch - if closeButton.exists { - closeButton.tap() - } - } } diff --git a/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift b/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift index b147558..f7ab8bb 100644 --- a/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift +++ b/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift @@ -1,166 +1,24 @@ -// -// CableUITestsScreenshotLaunchTests.swift -// CableUITestsScreenshot -// -// Created by Stefan Lange-Hegermann on 06.10.25. -// - import XCTest final class CableUITestsScreenshotLaunchTests: XCTestCase { - private func launchApp(arguments: [String] = []) -> XCUIApplication { - let app = XCUIApplication() - var launchArguments = ["--uitest-reset-data"] - launchArguments.append(contentsOf: arguments) - app.launchArguments = launchArguments - app.launch() - return app - } - private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement { - let collection = app.collectionViews["systems-list"] - if collection.waitForExistence(timeout: 6) { - return collection - } - - let table = app.tables["systems-list"] - XCTAssertTrue(table.waitForExistence(timeout: 6)) - return table - } - - private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement { - let collection = app.collectionViews["loads-list"] - if collection.waitForExistence(timeout: 6) { - return collection - } - - let table = app.tables["loads-list"] - XCTAssertTrue(table.waitForExistence(timeout: 6)) - return table - } - - - private func takeScreenshot(name: String, - lifetime: XCTAttachment.Lifetime = .keepAlways) { - let screenshot = XCUIScreen.main.screenshot() - let attachment = XCTAttachment(screenshot: screenshot) - attachment.name = name - attachment.lifetime = lifetime - add(attachment) - } - override class var runsForEachTargetApplicationUIConfiguration: Bool { false } - + override func setUpWithError() throws { continueAfterFailure = false } - - + @MainActor - func testOnboardingLoadsView() throws { - let app = launchApp(arguments: ["--uitest-reset-data"]) - takeScreenshot(name: "01-OnboardingSystemsView") + func testLaunch() throws { + let app = XCUIApplication() + app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"] + app.launch() - let createSystemButton = app.buttons["create-system-button"] - XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5)) - createSystemButton.tap() - takeScreenshot(name: "02-OnboardingLoadsView") - - let componentsTab = app.buttons["components-tab"] - XCTAssertTrue(componentsTab.waitForExistence(timeout: 3)) - componentsTab.tap() - - let libraryCloseButton = app.buttons["library-view-close-button"] - let browseLibraryButton = onboardingSecondaryButton(in: app) - XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5)) - browseLibraryButton.tap() - XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5)) - Thread.sleep(forTimeInterval: 10) - takeScreenshot(name: "04-ComponentSelectorView") - libraryCloseButton.tap() - - let createComponentButton = onboardingPrimaryButton(in: app) - XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5)) - createComponentButton.tap() - takeScreenshot(name: "03-LoadEditorView") - } - - func testWithSampleData() throws { - let app = launchApp(arguments: ["--uitest-sample-data"]) - - let systemsList = resolvedSystemsList(in: app) - - let firstSystemCell = systemsList.cells.element(boundBy: 0) - XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3)) - let systemName = firstSystemCell.staticTexts.firstMatch.label - - takeScreenshot(name: "05-SystemsWithSampleData") - - let rowButton = firstSystemCell.buttons.firstMatch - if rowButton.waitForExistence(timeout: 2) { - rowButton.tap() - } else { - firstSystemCell.tap() - } - - let navButton = app.navigationBars.buttons[systemName] - if !navButton.waitForExistence(timeout: 3) { - let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) - coordinate.tap() - XCTAssertTrue(navButton.waitForExistence(timeout: 3)) - } - - tapComponentsTab(in: app) - - let loadsElement = resolvedLoadsList(in: app) - XCTAssertTrue(loadsElement.waitForExistence(timeout: 6)) - - XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3)) - Thread.sleep(forTimeInterval: 1) - takeScreenshot(name: "06-AdventureVanLoads") - - let bomButton = app.buttons["system-bom-button"] - XCTAssertTrue(bomButton.waitForExistence(timeout: 2)) - bomButton.tap() - -// let bomView = app.otherElements["system-bom-view"] -// XCTAssertTrue(bomView.waitForExistence(timeout: 3)) -// -// Thread.sleep(forTimeInterval: 1) -// takeScreenshot(name: "07-AdventureVanBillOfMaterials") - } - - private func tapComponentsTab(in app: XCUIApplication) { - let button = componentsTabButton(in: app) - XCTAssertTrue(button.waitForExistence(timeout: 3)) - button.tap() - } - - private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement { - let button = app.buttons["create-component-button"] - if button.exists { return button } - return app.buttons["onboarding-primary-button"] - } - - private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement { - let button = app.buttons["select-component-button"] - if button.exists { return button } - return app.buttons["onboarding-secondary-button"] - } - - private func componentsTabButton(in app: XCUIApplication) -> XCUIElement { - let idButton = app.buttons["components-tab"] - if idButton.exists { - return idButton - } - - let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"] - for label in labels { - let button = app.buttons[label] - if button.exists { return button } - } - return app.tabBars.buttons.element(boundBy: 1) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) } } diff --git a/screenshot.config b/screenshot.config new file mode 100644 index 0000000..3a734fe --- /dev/null +++ b/screenshot.config @@ -0,0 +1,14 @@ +# Screenshot configuration for Cable (VoltPlan) + +SCHEME="CableScreenshots" +APP_BUNDLE_ID="app.voltplan.CableApp" +UITEST_BUNDLE_ID="com.yuzuhub.CableUITestsScreenshot" +OUTPUT_DIR="Shots/Screenshots" + +LANGUAGES=(de fr en es nl) + +# Format: "Simulator Name|Runtime|Slug|Device Type ID" +DEVICE_MATRIX=( + "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max" + "iPad Pro Screenshot|26.4|ipad-pro-13-inch-m4|com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M4-8GB" +) diff --git a/shooter.sh b/shooter.sh index 22b5064..af59062 100755 --- a/shooter.sh +++ b/shooter.sh @@ -1,102 +1,288 @@ #!/bin/bash set -euo pipefail -SCHEME="CableScreenshots" +# Kill all child processes on Ctrl-C +trap 'printf "\n\033[31m ✘\033[0m Interrupted — stopping all jobs...\n"; kill 0; exit 130' INT TERM + +# ─── Configuration ──────────────────────────────────────────────────────────── +# Override these via environment variables, a config file, or CLI argument. +# +# Config file format (shell): +# SCHEME="MyAppScreenshots" +# APP_BUNDLE_ID="com.example.myapp" +# UITEST_BUNDLE_ID="com.example.myapp.UITests" +# LANGUAGES=(en de fr) +# DEVICE_MATRIX=( +# "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max" +# ) +# OUTPUT_DIR="Shots/Screenshots" + +CONFIG_FILE="${1:-./screenshot.config}" +if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck source=/dev/null + source "$CONFIG_FILE" +fi + +# Required — must be set in config or environment +SCHEME="${SCHEME:?Set SCHEME in $CONFIG_FILE or environment}" +APP_BUNDLE_ID="${APP_BUNDLE_ID:?Set APP_BUNDLE_ID in $CONFIG_FILE or environment}" +UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-}" + +# Optional with defaults +OUTPUT_DIR="${OUTPUT_DIR:-Shots/Screenshots}" +DERIVED_DATA="${DERIVED_DATA:-DerivedData-Screenshots}" RESET_SIMULATOR="${RESET_SIMULATOR:-1}" -APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}" -UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}" +PARALLEL="${PARALLEL:-1}" +VERBOSE="${VERBOSE:-0}" +STATUS_BAR_TIME="${STATUS_BAR_TIME:-9:41}" + +if [[ -z "${LANGUAGES+x}" ]]; then + LANGUAGES=(en) +fi + +if [[ -z "${DEVICE_MATRIX+x}" ]]; then + DEVICE_MATRIX=( + "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max" + ) +fi + +# ─── Pretty output ──────────────────────────────────────────────────────────── +BOLD="\033[1m" +DIM="\033[2m" +GREEN="\033[32m" +RED="\033[31m" +CYAN="\033[36m" +RST="\033[0m" + +ok() { printf "${GREEN} ✔${RST} %s\n" "$*"; } +fail() { printf "${RED} ✘${RST} %s\n" "$*"; } +info() { printf "${CYAN} ›${RST} %s\n" "$*"; } +step() { printf "\n${BOLD}%s${RST}\n" "$*"; } is_truthy() { case "$1" in 1|true|TRUE|yes|YES|on|ON) return 0 ;; - 0|false|FALSE|no|NO|off|OFF|"") return 1 ;; - *) return 0 ;; + *) return 1 ;; esac } -DEVICE_MATRIX=( - "iPhone 17 Pro Max|26.0|iphone-17-pro-max" - "iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4" -) - +# ─── Dependency check ───────────────────────────────────────────────────────── command -v xcparse >/dev/null 2>&1 || { - echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2 + fail "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" exit 1 } -# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1) +# ─── Simulator helpers ──────────────────────────────────────────────────────── + resolve_udid() { local name="$1"; local os="$2" if [[ -n "$os" ]]; then - # Prefer Shutdown state for a clean start - xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \ - '$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}' + xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' ' + /^--.*--$/ { in_section = ($0 ~ o) } + in_section && $0 ~ n { print $2; exit } + ' else - xcrun simctl list devices | awk -v n="$name" -F '[()]' \ - '$0 ~ n && /Shutdown/ {print $2; exit}' + xcrun simctl list devices | awk -v n="$name" -F '[()]' ' + $0 ~ n { print $2; exit } + ' fi } -for device_entry in "${DEVICE_MATRIX[@]}"; do - IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry" - echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ===" +ensure_simulator() { + local name="$1"; local runtime="$2"; local device_type="$3" + local udid + udid=$(resolve_udid "$name" "$runtime") + if [[ -n "$udid" ]]; then + echo "$udid" + return 0 + fi - for lang in de fr en es nl; do - echo "Resetting simulator for a clean start..." - UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") - if [[ -z "$UDID" ]]; then - UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}') - fi - if [[ -z "$UDID" ]]; then - echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1 - fi + local runtime_id + runtime_id=$(xcrun simctl list runtimes | awk -v r="iOS $runtime" '$0 ~ r {for(i=1;i<=NF;i++) if($i ~ /com\.apple/) {print $i; exit}}') + if [[ -z "$runtime_id" ]]; then + fail "Runtime iOS $runtime not found" >&2 + return 1 + fi - xcrun simctl shutdown "$UDID" || true - if is_truthy "$RESET_SIMULATOR"; then - xcrun simctl erase "$UDID" + info "Creating simulator: $name (iOS $runtime)" >&2 + udid=$(xcrun simctl create "$name" "$device_type" "$runtime_id") + echo "$udid" +} + +prepare_simulator() { + local udid="$1" lang="$2" + local region + region=$(echo "$lang" | tr '[:lower:]' '[:upper:]') + + xcrun simctl boot "$udid" 2>/dev/null || true + + # Language & locale + xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLanguages -array "$lang" + xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}" + + # Suppress notifications + xcrun simctl spawn "$udid" defaults write com.apple.springboard DoNotDisturb -bool true 2>/dev/null || true + xcrun simctl spawn "$udid" defaults write com.apple.generativeexperiences.corefollowup \ + DateOfLastAppleIntelligenceReadinessCFU -date "2020-01-01T00:00:00Z" 2>/dev/null || true + xcrun simctl spawn "$udid" defaults write com.apple.corefollow DisableFollowUp -bool true 2>/dev/null || true + xcrun simctl spawn "$udid" defaults write com.apple.corefollowup DisableFollowUp -bool true 2>/dev/null || true + + # Reboot for language change + xcrun simctl shutdown "$udid" 2>/dev/null || true + xcrun simctl boot "$udid" + + # Clean status bar + xcrun simctl status_bar "$udid" override \ + --time "$STATUS_BAR_TIME" \ + --batteryState charged --batteryLevel 100 \ + --wifiBars 3 \ + 2>/dev/null || true +} + +# ─── Build ──────────────────────────────────────────────────────────────────── + +build_for_testing() { + local device_entry="$1" + IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry" + + local udid + udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type") + if [[ -z "$udid" ]]; then + fail "Could not resolve or create simulator for $dev_name"; return 1 + fi + + info "Building $dev_name..." + local log_file="/tmp/shooter-build-${dev_slug}.log" + if xcodebuild build-for-testing \ + -scheme "$SCHEME" \ + -destination "id=$udid" \ + -derivedDataPath "$DERIVED_DATA" \ + -quiet \ + > "$log_file" 2>&1; then + ok "Build succeeded ($dev_name)" + else + fail "Build failed ($dev_name)" + cat "$log_file" + return 1 + fi +} + +# ─── Test one language ──────────────────────────────────────────────────────── + +run_one_language() { + local device_entry="$1" + local lang="$2" + IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry" + + local label="${dev_slug}/${lang}" + + local udid + udid=$(resolve_udid "$dev_name" "$dev_runtime") + if [[ -z "$udid" ]]; then + udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type") + fi + if [[ -z "$udid" ]]; then + fail "[$label] Could not resolve simulator"; return 1 + fi + + # Reset + xcrun simctl shutdown "$udid" 2>/dev/null || true + if is_truthy "$RESET_SIMULATOR"; then + xcrun simctl erase "$udid" + else + for bundle in "$APP_BUNDLE_ID" ${UITEST_BUNDLE_ID:+"$UITEST_BUNDLE_ID"}; do + xcrun simctl terminate "$udid" "$bundle" 2>/dev/null || true + xcrun simctl uninstall "$udid" "$bundle" 2>/dev/null || true + done + fi + + udid=$(resolve_udid "$dev_name" "$dev_runtime") + prepare_simulator "$udid" "$lang" + + local bundle="results-${dev_slug}-${lang}.xcresult" + local outdir="${OUTPUT_DIR}/${dev_slug}/$lang" + rm -rf "$bundle" "$outdir" + mkdir -p "$outdir" + + local log_file="/tmp/shooter-test-${dev_slug}-${lang}.log" + info "[$label] Testing..." + + local test_exit=0 + xcodebuild test-without-building \ + -scheme "$SCHEME" \ + -destination "id=$udid" \ + -derivedDataPath "$DERIVED_DATA" \ + -resultBundlePath "$bundle" \ + > "$log_file" 2>&1 || test_exit=$? + + if [[ $test_exit -eq 0 ]]; then + xcparse screenshots "$bundle" "$outdir" > /dev/null 2>&1 + local count + count=$(find "$outdir" -name '*.png' 2>/dev/null | wc -l | tr -d ' ') + ok "[$label] ${count} screenshots" + else + fail "[$label] Tests failed" + grep -E '(error:|FAIL|failed)' "$log_file" | head -20 + if is_truthy "$VERBOSE"; then + printf "${DIM}"; cat "$log_file"; printf "${RST}" else - for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do - if [[ -n "$bundle" ]]; then - xcrun simctl terminate "$UDID" "$bundle" || true - xcrun simctl uninstall "$UDID" "$bundle" || true - fi - done + printf " ${DIM}Full log: $log_file${RST}\n" fi - echo "Running screenshots for $lang" - region=$(echo "$lang" | tr '[:lower:]' '[:upper:]') + fi - UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") - if [[ -z "$UDID" ]]; then - UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}') - fi - if [[ -z "$UDID" ]]; then - echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1 - fi + xcrun simctl shutdown "$udid" 2>/dev/null || true + return $test_exit +} - xcrun simctl boot "$UDID" || true - xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang" - xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}" - xcrun simctl shutdown "$UDID" || true - xcrun simctl boot "$UDID" - xcrun simctl status_bar booted override \ - --time "9:41" \ - --batteryState charged --batteryLevel 100 \ - --wifiBars 3 - - xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true +# ─── Main ───────────────────────────────────────────────────────────────────── - bundle="results-${DEVICE_SLUG}-${lang}.xcresult" - outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang" - rm -rf "$bundle" "$outdir" - mkdir -p "$outdir" +step "Configuration" +info "Scheme: $SCHEME" +info "Bundle ID: $APP_BUNDLE_ID" +info "Output: $OUTPUT_DIR" +info "Languages: ${LANGUAGES[*]}" +info "Devices: ${#DEVICE_MATRIX[@]}" - xcodebuild test \ - -scheme "$SCHEME" \ - -destination "id=$UDID" \ - -resultBundlePath "$bundle" - - xcparse screenshots "$bundle" "$outdir" - echo "Exported screenshots to $outdir" - xcrun simctl shutdown "$UDID" || true - done +step "Building for testing" +for device_entry in "${DEVICE_MATRIX[@]}"; do + build_for_testing "$device_entry" done + +step "Running screenshot tests" +total_runs=$((${#DEVICE_MATRIX[@]} * ${#LANGUAGES[@]})) +info "${#DEVICE_MATRIX[@]} devices × ${#LANGUAGES[@]} languages = ${total_runs} runs" + +failed=0 + +if is_truthy "$PARALLEL"; then + info "Devices run in parallel" + pids=() + for device_entry in "${DEVICE_MATRIX[@]}"; do + ( + for lang in "${LANGUAGES[@]}"; do + run_one_language "$device_entry" "$lang" || true + done + ) & + pids+=($!) + done + for pid in "${pids[@]}"; do + wait "$pid" || failed=$((failed + 1)) + done +else + for device_entry in "${DEVICE_MATRIX[@]}"; do + for lang in "${LANGUAGES[@]}"; do + run_one_language "$device_entry" "$lang" || failed=$((failed + 1)) + done + done +fi + +# ─── Summary ────────────────────────────────────────────────────────────────── +step "Done" +total_screenshots=$(find "$OUTPUT_DIR" -name '*.png' 2>/dev/null | wc -l | tr -d ' ') +if [[ $failed -eq 0 ]]; then + ok "All runs passed — ${total_screenshots} screenshots in ${OUTPUT_DIR}/" +else + fail "${failed} run(s) had errors — ${total_screenshots} screenshots in ${OUTPUT_DIR}/" + printf " ${DIM}Re-run with VERBOSE=1 for full logs${RST}\n" + exit 1 +fi