Automate App Store screenshots via XCUITests
Replace preview-based screenshot rendering with simulator XCUITests driven by shooter.sh (per-language locale + status bar overrides, xcparse extraction). Add batteries-list accessibility identifier and update CLAUDE.md screenshot docs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
68
CLAUDE.md
68
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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
14
screenshot.config
Normal file
14
screenshot.config
Normal file
@@ -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"
|
||||
)
|
||||
328
shooter.sh
328
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
|
||||
|
||||
Reference in New Issue
Block a user