90 frames: six languages (en, ru, es, ja, zh-Hans, ar) across two sets — website and App Store — in two themes each. Shooting the MeteoHealth list by hand takes days. And not only in time: a human forgets to open the "Sleep" sheet precisely after "Analytics" has rendered its correlations, not before.
I built a harness on XCUITest — four test classes that together cover 86 frames out of 90 without a single human tap. The remaining four are heart-rate frames shot with the camera: the simulator physically has no camera. Below is how the factory works and what it found beyond the screenshots themselves.
A seeder behind a launch argument#
Running a real user profile is out of the question: it has no 30-day history, no correlations between metrics, and above all, everyone's is different.
The demo profile ScreenshotSeederSite lives entirely behind the launch argument MH_SCREENSHOT_SEED and does absolutely nothing in a regular build — it's a no-op, code that physically never executes without an explicit launch argument. The seeder writes the cycle, smoking, medications with an intake history, water, energy and stress history — and separately, behind a second argument MH_SCREENSHOT_SEED_HEALTH, 30 days of HealthKit: sleep with phases, HRV, resting heart rate, steps.
The split is deliberate: the "Sleep" and "Stress" screens read HealthKit directly and keep no copy of their own in Core Data. Without it, they come out empty in the frame — something you learn not from the documentation but from the first run with black cards.
func test_0_prepareDemoProfile() {
let app = XCUIApplication()
app.launchArguments += [
"SKIP_ONBOARDING",
"MH_SCREENSHOT_SEED",
"MH_SCREENSHOT_SEED_HEALTH", // only here: see ScreenshotSeederSite
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
"-\(themeDefaultsKey)", Theme.light.rawValue
]
app.launch()
acceptAllHealthSheets(app)
sleep(25) // writing the seed into Core Data and HealthKit
...
}The only test in the entire suite that ever sees the system Health access dialog is this one, and it runs first by name (test_0_). After that the permission is already granted, a repeated seed is skipped by a non-empty-database check, and the remaining 340 lines of the class never encounter that dialog again.
Routes instead of searching by labels#
Frames 6–13 are the detail sheets on "Today" (sleep, stress, cycle, nutrition, medications, smoking) plus the Analytics sections. Opening "Today" and finding a card by tapping its label won't work: the cards are sorted dynamically, and the order differs from language to language. A test that locates "Sleep" by its position in English will open something else entirely in the Japanese frame.
The solution is to bypass UI search altogether. The screen opens directly via a launch argument:
private func captureTodaySheet(_ route: String, name: String, language: String, locale: String, theme: Theme) {
let app = launch(
language: language, locale: locale, theme: theme,
extraArguments: ["MH_SCREENSHOT_ROUTE", route]
)
sleep(4) // the sheet opens from onAppear, content loads in afterwards
shoot(name)
app.terminate()
}The Analytics section works the same way, but with its own argument MH_SCREENSHOT_ANALYTICS. The "Forecast" frame has a subtlety: before shooting, the test first spends 14 seconds inside "Analytics" and only then switches to "Forecast".
The reason isn't superstition — correlations are computed once, when Analytics opens (CorrelationEngine.correlations lives in the engine's memory), and the risk card on "Forecast" reads the already-finished result. Without that detour it will honestly render "learning your rhythm", no matter how much history sits in the database.
SpringBoard is hostile territory#
Frame 5 — widgets on the home screen — is the only part of the set that lives not in the app but in SpringBoard, and that changes everything. SiteWidgetsUITests at 462 lines is the longest file in the factory, and most of that volume is defense against SpringBoard answering differently than you expect.
Three traps, each discovered not in the documentation but through an actually ruined set of frames.
The first: springboard.icons returns an empty list right after the simulator reboots with a new language, even though the widgets are physically on screen — SpringBoard is still rebuilding the home screen while the query already returns an answer. The cure is not a timeout but a retry:
private func existingWidgetCount() -> Int {
var count = 0
for attempt in 0 ..< 6 {
count = springboard.icons
.matching(NSPredicate(format: "identifier == %@", "MeteoHealth"))
.allElementsBoundByIndex
.filter { $0.frame.width > 200 }
.count
if count >= 2 { return count }
if attempt < 5 { sleep(4) }
}
return count
}A comment in the code honestly names the price of the first version: the test believed the empty answer, went off to delete and re-add widgets that were already in place, and got stuck on the deletion confirmation — which is held hostage by the second trap.
The second: buttons in system alerts regularly report isHittable == false, and a plain tap() on them silently does nothing — it doesn't fail, doesn't report an error, the loop just spins until the timeout. This is the same mechanism that ruined the widgets-es set: the deletion alert stayed on screen, and the entire run kept crashing into it. The workaround is the same everywhere — a tap at the coordinate of the element's center:
func tapCenter(_ element: XCUIElement) {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}The third: addressing with no labels at all. The "+" button that opens the home screen editing menu is called «+» in Russian and «Editar» in Spanish — in the system language, which changes on every frame. The only working path is coordinate-based, anchored to screen geometry rather than to text.
RTL and iPad: where even page order gets mirrored#
Arabic adds mirroring. The widget-variant carousel in the SpringBoard configurator was only ever swiped right-to-left, and in an RTL interface that direction means "back". Eight attempts in a row returned the same already-added variant, the function honestly reported "not found", and the widgets-ar set came out with one widget instead of two. The fix — try both directions:
let directions: [(from: CGFloat, to: CGFloat)] = [(0.85, 0.15), (0.15, 0.85)]
for direction in directions {
for _ in 0 ..< 8 {
// ...
}
}The same applies to home screen pages: after a language change there's no telling which swipe direction leads to the widgets, so the capture tries both directions instead of guessing one.
iPad adds a dimension not of language but of layout. There the tab bar sits at the top, not the bottom, and the coordinate fallback tuned for the iPhone bottom bar opened the wrong tab — an energy card, say, instead of the intended screen. The solution is more elegant than coordinates: the native TabView exposes tab buttons with an identifier equal to the name of the SF Symbol from tabItem — a little-known but reliable fact that works in both layouts:
let order: [(symbol: String, id: String, slot: Int, name: String)] = [
("cloud.sun.fill", "tab_forecast", 1, "5_forecast"),
("chart.xyaxis.line", "tab_analytics", 3, "3_analytics"),
("book.fill", "tab_journal", 2, "4_journal")
]Coordinates remained only as an iPhone fallback. Scrolling went the same way: swipeUp() timed out on the heavy Analytics dashboard on iPad — it requires a snapshot of the entire hierarchy; a coordinate drag builds no snapshot. Files from the two devices diverge by the iPhone_/iPad_ prefix based on UIDevice.current.userInterfaceIdiom.
That's where the traps of the capture itself end. The harder question turned out to be not "how do I take the frame" but "what should the frame show".
Demo data must pass statistics#
The factory's least trivial finding: demo data is code too, and code with obligations. The "Forecast" screen must show a meaningful conclusion about the link between pressure and well-being, not "learning your rhythm". The first version of the seeder wrote the relationship as a step function: "dropped → 2…4, otherwise 6…9".
Over 30 days that yields r ≈ 0.45, raw p ≈ 0.012 — seemingly significant. But the engine runs a Benjamini-Hochberg correction across all tested factor pairs, of which there are about ~100, and after the correction the p-value drowns. The screen honestly flagged its own conclusion as preliminary — right in the App Store frame.
The fix replaced the step with a linear dependency: r ≈ 0.72, t = 5.5, while the spread of individual ratings stays full — from 1 to 10. A relationship like that survives the correction and yields a confident verdict.
The second case is of the same nature but inverted: a correlation appeared where none was ordered. The seeded energy history happened to line up in phase with the temperature wave, and "Correlations" opened with the line "Temperature → Energy +0.96" — a coefficient that never occurs on live data. The energy history was decoupled from temperature with a separate wave, so that the expected correlation comes first — "Pressure drop → Well-being +0.72".
The moral for any demo-data factory: if the app displays a statistical conclusion, the demo profile is obligated to generate data that honestly passes the same statistics as a live user's data. But statistics isn't the only thing an honest frame lays bare.
Frames find what tests don't#
The project has over 900 green tests, and not one of them caught the eight real defects that surfaced during an ordinary review of the finished frames. Tests verify that the code does what was intended; frames show what the user sees — and those are not always the same thing.
Among the finds: a symptom in the Journal list rendered as the raw key symptom.jointPain instead of the localized "Joint Pain" — the detail screen had been localizing it for ages, while the list row glued the array together as-is. A medication course started today showed 0% adherence — the raw date difference gave zero full days, and that zero nulled out the percentage in the denominator. On the Japanese and Chinese summary widget the string "Good Sleep" hung there instead of the translated factor name — the snapshot stored the English factor.name, while the screens render it through localizedName.
None of these bugs belongs to the capture logic — they are all in the product. They could only be noticed because the frames exist in all six languages at once, not because someone wrote an assert on a specific string.
Why Do All This, Besides Saving Time#
Automated capture pays off in more than time, though days of manual work reduced to a run of a test target is a weighty argument on its own. It forces you to look at the app through the user's eyes in every language at once, in both themes, in both device layouts — an angle that logic tests structurally never cover. Launch arguments instead of UI search remove the dependency on element order on screen. Coordinate taps and both swipe directions are the mandatory minimum for SpringBoard and RTL, where labels can't be read and elements lie about isHittable. And a separate lesson for an app with statistics inside: demo data is product code, and it must pass the same significance testing as a real person's data.



