posts

How I Check AI's UI Changes Without Running My App

When AI changes my app's UI or puts up a big pull request, I don't want to check out the branch and run the app just to see if it looks right. So my UI tests take screenshots in light and dark mode and CI puts them on a page linked from the pull request. Here's how it works and how to set up your own.

When AI makes a change to Tobi’s UI, or puts up a big pull request that touches a bunch of screens, I want to see it before I merge it. Reading a SwiftUI diff doesn’t tell me if the keyboard is covering a button or if dark mode looks off, and checking out every branch to run it in the simulator gets old fast.

So now the UI tests take a screenshot of every screen they walk through, in light and dark mode, and CI publishes them to uiproof.gettobi.com. Each pull request gets its own gallery, and a comment on the pull request links to it.

A gallery page: onboarding screens from the UI tests in a grid, dark and light side by side, each captioned with the step, appearance, and device

Here’s how it works.

The stack

There’s no framework or build step for the gallery itself. It’s plain HTML and the PNGs.

The full flow

flowchart TD A[iOS pull request] --> B{UI tests changed?} B -->|No| C[Unit tests only] B -->|Yes| D[Run UI tests in light mode] D --> E[Run UI tests in dark mode] E --> F[Build the gallery page] F --> G[Upload as an artifact] G --> H[Rebuild the site with every open gallery] H --> I[Deploy the Worker] I --> J[Comment the gallery link on the PR]

The UI tests only run when something in Tobi/TobiUITests changed, since they’re the slow part of CI. The unit tests run either way.

Taking the screenshots

The UI tests launch the app with a -UITesting argument so it starts signed out, then walk through onboarding: the landing screen, naming the home, adding a first task, and so on until the app shell. At each step the test waits for the screen to show up, fills in the same sample data every time (“The Wilson House”, a furnace, “Replace air filter”), and takes a screenshot.

Before every screenshot, the test dismisses the keyboard and fails if it’s still showing, because I want the gallery to show the screen the way someone would actually see it. It also checks a few pixels near the top corners to make sure a light mode screenshot is actually light and a dark mode one is actually dark. Without that, a run where the appearance didn’t switch would publish two copies of the same screen with different labels.

Saving the screenshot happens in a small helper. It attaches the image to the test results like normal, and if a screenshot directory was passed in, it also writes a PNG there:

enum ScreenshotCapture {
  static func save(_ name: String, screenshot: XCUIScreenshot, testCase: XCTestCase) {
    let attachment = XCTAttachment(screenshot: screenshot)
    attachment.name = name
    attachment.lifetime = .keepAlways
    testCase.add(attachment)

    let dir = ProcessInfo.processInfo.environment["UITEST_SCREENSHOT_DIR"] ?? ""
    guard !dir.isEmpty else { return }

    let slug = ProcessInfo.processInfo.environment["UITEST_DEVICE_SLUG"] ?? "device"
    let fileName = "\(name)-\(slug).png"
    let url = URL(fileURLWithPath: dir).appendingPathComponent(fileName)
    do {
      try screenshot.pngRepresentation.write(to: url)
    }
    catch {
      XCTFail("Failed to write screenshot \(fileName): \(error)")
    }
  }
}

The name has the step number and the appearance in it, and the device gets added on the end, so you end up with files like 01-onboarding-landing-light-iphone-18-pro.png. The number keeps them in order, and the rest becomes the caption on the gallery page.

The one trick here is getting those environment variables into the test. xcodebuild doesn’t pass your environment through to the test runner, but anything prefixed with TEST_RUNNER_ gets passed along with the prefix stripped off. So the workflow sets TEST_RUNNER_UITEST_SCREENSHOT_DIR, and the test reads UITEST_SCREENSHOT_DIR.

To get both appearances, the workflow runs the UI tests twice on an iPhone 18 Pro simulator, switching the simulator’s appearance before each run. Here’s the loop, trimmed down:

export TEST_RUNNER_UITEST_SCREENSHOT_DIR="$GITHUB_WORKSPACE/.ui-screenshots"
export TEST_RUNNER_UITEST_DEVICE_SLUG="iphone-18-pro"

for style in light dark; do
  xcrun simctl ui "$SIM_UDID" appearance "$style"
  export TEST_RUNNER_UITEST_INTERFACE_STYLE="$style"
  xcodebuild \
    -project Tobi.xcodeproj \
    -scheme TobiUITests \
    -destination "platform=iOS Simulator,id=$SIM_UDID" \
    test-without-building
done

Building the page

scripts/build-ui-test-gallery.sh takes the folder of PNGs and writes out a gallery: the images, an index.html that lays them out in a grid, and a files.json with a list of the filenames. The captions come straight from the filenames, so 01-onboarding-landing-light-iphone-18-pro.png shows up as “01-onboarding-landing — light — iphone-18-pro”. Clicking an image opens the full-size PNG.

You’ll never look at files.json yourself, but the deploy step needs it to know which files belong to each gallery. I’ll get to why in a second.

The Mac runner uploads that folder as a GitHub Actions artifact, and a second job on a Linux runner takes it from there.

Hosting it on a Cloudflare Worker

The site is a Cloudflare Worker with no code, just static assets. Here’s the whole config:

{
  "name": "tobi-ui-proof",
  "compatibility_date": "2026-09-22",
  "workers_dev": true,
  "routes": [
    {
      "pattern": "uiproof.gettobi.com",
      "custom_domain": true,
    },
  ],
  "assets": {
    "directory": "./public",
  },
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1,
  },
}

Here’s the catch. Every wrangler deploy replaces all of the assets. If I deploy a public/ folder with only the new pull request’s gallery in it, every other gallery disappears, and since they aren’t stored anywhere else, they’d be gone for good.

So before deploying, the publish script rebuilds the whole site from what’s currently live:

  1. Copy the new gallery into public/pr/<n>/.
  2. Download galleries.json from the live site, which lists every gallery that’s up right now.
  3. For every other gallery in that list, download its index.html, its files.json, and every PNG listed in files.json.
  4. Write a new galleries.json and index page.
  5. Deploy.

If any of those downloads fail, the script stops and doesn’t deploy. Deploying anyway would publish a site that’s missing that gallery, and because the live site is the only copy, it wouldn’t come back.

I learned that one the hard way. Workers redirects /pr/<n>/index.html to /pr/<n>/ with a 307, and the first version of the script treated that redirect as a failed download. It skipped every other gallery and happily deployed a site with just one in it, so the index only ever showed whichever pull request published last. Now it follows redirects, asks for the trailing-slash URL, and refuses to deploy if anything is missing.

The other problem was two pull requests publishing at the same time. Both would read the live site, neither would know about the other’s new gallery, and whichever deployed second would wipe out the first. The publish and cleanup jobs share a concurrency group, so they wait in line instead:

concurrency:
  group: tobi-ui-proof-deploy
  cancel-in-progress: false

The site keeps up to 20 galleries, newest pull requests first.

Commenting on the pull request

After deploying, an actions/github-script step leaves a comment on the pull request with the gallery link. The comment includes a hidden HTML marker, <!-- tobi-ui-proof-gallery -->, so the next run can find that comment and update it instead of adding a new one every time the tests run.

Cleaning up

When a pull request is closed or merged, a cleanup job runs the same rebuild, leaves that pull request’s gallery out, and deploys again. Galleries only stick around while the pull request is open.

Setting this up yourself

  1. Write screenshots to disk from your UI tests. Read a directory from an environment variable and write screenshot.pngRepresentation there. Put the order and the appearance in the filename so you don’t need anything else to label them.
  2. Pass the directory in with TEST_RUNNER_. Set TEST_RUNNER_UITEST_SCREENSHOT_DIR when you run xcodebuild. If you want light and dark, run the tests twice and switch the simulator with xcrun simctl ui before each run.
  3. Turn the folder into a page. Any script works. Write an index.html for the images and a files.json that lists them.
  4. Host it. If your host replaces everything on each deploy like Workers static assets do, download the galleries that are already live before you deploy, and don’t deploy if any of those downloads fail. If your host lets you upload one folder at a time, you can skip that part.
  5. Run deploys one at a time. Put publish and cleanup in the same concurrency group so they can’t overwrite each other.
  6. Comment the link on the pull request, with a hidden marker so you can update the same comment.
  7. Delete the gallery when the pull request closes.

Wrapping up

Now when a pull request changes the UI, I can see every screen in light and dark without checking out the branch or opening Xcode. The screenshots come from the same test run that checked them, so if the keyboard was in the way or dark mode didn’t apply, the test failed and nothing got published. And since galleries get cleaned up when pull requests close, the site only ever has what’s open right now.

// open to work

I'm looking for my next thing, whether that's full-time, consulting, or a long-term contract. If you're building something where the details matter, I'd like to hear about it. Send me an email.

Jul 12, 2026 tobiemailship-a-ton
I Argued Myself Into Sending an Email Nobody Wanted
I wrote a fix to stop Tobi's daily digest from sending on days with zero tasks, then canceled it with a confident argument about why users needed those emails. Three days later a TestFlight tester told me the zero-task email made no sense, and the original fix shipped the same day.
Jul 10, 2026 analyticstobiship-a-ton
Why Weekly Active Users Is the Number I Watch in Tobi
Tobi went to public beta this week. Before it did, I picked one number to watch: weekly active users.
Jul 8, 2026 workflowaiship-a-tontobi
Idea to TestFlight in Three Weeks
Three weeks ago Tobi was an idea in my head. Yesterday it hit feature complete on TestFlight. Here's the AI workflow that got it there.