# iOS Simulator screenshots in pull requests

Canonical URL: https://macswarm.com/docs/ios-simulator-screenshots-in-pull-requests/
Last reviewed: 2026-10-26

> Capture iOS Simulator screenshots with simctl, XCTAttachment and xcresulttool, diff them with swift-snapshot-testing, and post them to a pull request.

A reviewer should see the changed screen without checking out the branch. This guide captures Simulator screenshots on one named device, exports them from the test result, compares them with snapshot baselines and attaches them to a GitHub pull request, by hand or through Claude Code or Codex.

## How do I get iOS UI test screenshots into a pull request?

Capture them on one named iOS Simulator, export them from the test result, then attach them where GitHub renders images. In an XCUITest, wait for the screen with `waitForExistence(timeout:)`, wrap `XCUIScreen.main.screenshot()` in an `XCTAttachment` and set its lifetime to `.keepAlways`, because the default discards attachments of passing tests. Run `xcodebuild test` into a new `-resultBundlePath` every time, then `xcrun xcresulttool export attachments` writes the images plus a `manifest.json` that names them. Pick your files from the manifest and post them with `gh pr comment --attach`, which needs GitHub CLI 2.99.0 or later and push access. For regressions, swift-snapshot-testing compares each screen with a reference PNG kept in the repository; when the reference has to change, a reviewer approves it, not the agent. Claude Code or Codex can run the capture and export script for you.

How this page was checked: every command matches Apple's tool help (`xcrun simctl help`, `xcrun xcresulttool help`, `man xcodebuild`) and the linked documentation, read on 26 Oct 2026. The Mac we wrote this on has no iOS Simulator runtime, so nothing here was run on a Simulator. Run the scripts on your project before relying on them. `MyApp`, the target and test names, the accessibility identifiers, the UDID and the pull request number are placeholders.

## How do I capture a Simulator screenshot from the command line?

`simctl` is the Simulator command-line tool that ships with Xcode. Address one device by its UDID: the `booted` shortcut is ambiguous when several Simulators are running.

```bash
xcrun simctl list devices available        # copy one UDID from the list
UDID=<udid>

xcrun simctl bootstatus "$UDID" -b         # boot it if needed and wait until it is ready
xcrun simctl ui "$UDID" appearance light
xcrun simctl ui "$UDID" content_size large
xcrun simctl status_bar "$UDID" override --time 9:41
xcrun simctl install "$UDID" path/to/MyApp.app
xcrun simctl launch "$UDID" com.example.MyApp
xcrun simctl io "$UDID" screenshot settings-light.png
xcrun simctl status_bar "$UDID" clear
```

`screenshot` writes a PNG by default; `--type` also takes `tiff`, `bmp`, `gif` and `jpeg`, and `recordVideo` records a movie. Appearance and text size stay as set until you change them, so set them at the start of every run; `status_bar clear` removes the override. Apple's archived [Simulator guide](https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/InteractingwiththeiOSSimulator/InteractingwiththeiOSSimulator.html) documents the screenshot operation; the other options come from `xcrun simctl help`. A command-line capture cannot tell when the screen is ready, so take screenshots you will compare inside a UI test. An agent can also capture through the `screenshot` tool of XcodeBuildMCP, which runs the same `simctl` command; [XcodeBuildMCP setup](https://macswarm.com/docs/xcodebuildmcp-on-a-cloud-mac/) covers it.

## How do I take a screenshot inside an XCUITest?

Wait for the element that proves the screen is ready, then capture. `XCUIScreen.main.screenshot()` returns the screen and `XCTAttachment(screenshot:)` adds it to the result bundle ([Apple: XCUIScreenshot](https://developer.apple.com/documentation/xcuiautomation/xcuiscreenshot), [waitForExistence](https://developer.apple.com/documentation/xcuiautomation/xcuielement/waitforexistence(timeout:))).

```swift
import XCTest

final class SettingsScreenshots: XCTestCase {
    @MainActor
    func testSettingsScreen() {
        let app = XCUIApplication()
        app.launchArguments += ["-ui-testing"]  // your app's own flag: fixed data, no animations
        app.launch()

        app.buttons["settings-tab"].tap()
        XCTAssertTrue(app.staticTexts["settings-title"].waitForExistence(timeout: 10))

        let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
        attachment.name = "settings"
        attachment.lifetime = .keepAlways
        add(attachment)
    }
}
```

`settings-tab` and `settings-title` are accessibility identifiers you set in the app. `-ui-testing` is not an Apple flag; it stands for your app's switch to fixed seed data without animations, so two runs draw the same pixels. Set `lifetime` to `.keepAlways`: the default, `.deleteOnSuccess`, discards the attachment when its test passes ([Apple: lifetime](https://developer.apple.com/documentation/xctest/xctattachment/lifetime-swift.property)), the usual reason a green run returns no screenshots.

## How do I run the tests and export screenshots repeatably?

Use one script for people and agents. This one runs light and dark, writes each run to its own folder, records the environment, picks screenshots from the export manifest and keeps the test exit status.

```bash
#!/bin/bash
# scripts/ui-evidence.sh <udid>
set -uo pipefail
UDID="$1"
RUN="build/evidence/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$RUN"
{ date -u; sw_vers; xcodebuild -version; } > "$RUN/environment.txt"
xcrun simctl list -j devices > "$RUN/devices.json"

xcrun simctl bootstatus "$UDID" -b
xcrun simctl ui "$UDID" content_size large
xcrun simctl status_bar "$UDID" override --time 9:41

status=0
for mode in light dark; do
  xcrun simctl ui "$UDID" appearance "$mode"
  TEST_RUNNER_SNAPSHOT_TESTING_RECORD=never xcodebuild test \
    -project MyApp.xcodeproj -scheme MyApp \
    -destination "platform=iOS Simulator,id=$UDID" \
    -testLanguage en -testRegion US \
    -only-testing:MyAppTests/SettingsSnapshotTests \
    -only-testing:MyAppUITests/SettingsScreenshots \
    -resultBundlePath "$RUN/$mode.xcresult" || status=$?

  xcrun xcresulttool export attachments \
    --path "$RUN/$mode.xcresult" --output-path "$RUN/$mode"
  jq -r '.[].attachments[]
         | select(.suggestedHumanReadableName | startswith("settings"))
         | .exportedFileName' "$RUN/$mode/manifest.json" |
    while read -r f; do cp "$RUN/$mode/$f" "$RUN/settings-$mode.png"; done
done

xcrun simctl status_bar "$UDID" clear
exit "$status"
```

A new folder per run matters because `xcodebuild` will not write a result bundle over an existing path. The export always runs, so a failing test still leaves its screenshots, and the script exits with the test status. The manifest lists, per test, each attachment's `exportedFileName` and `suggestedHumanReadableName` (`xcresulttool export attachments --schema`). We have not seen this test's manifest, so check that the readable name starts with the attachment name before trusting the filter. `TEST_RUNNER_` passes the variable to the test process (`man xcodebuild`), which the next section explains.

## How do I add snapshot tests with swift-snapshot-testing?

Point-Free's [swift-snapshot-testing](https://github.com/pointfreeco/swift-snapshot-testing), at 1.19.7 since 21 Oct 2026, compares a rendered view with a reference PNG stored next to the test. It is a different kind of evidence from the XCUITest screenshot: `.image(on: .iPhoneX)` renders a view controller inside the unit test process at that device's size, not the running app.

```swift
import SnapshotTesting
import Testing
@testable import MyApp

@MainActor
struct SettingsSnapshotTests {
    @Test func settingsScreen() {
        let vc = SettingsViewController()
        assertSnapshot(of: vc, as: .image(on: .iPhoneX))
    }
}
```

By default the library records a reference it cannot find and fails the test, writing it to a `__Snapshots__` folder. For verification, set the record mode to `never` (the `SNAPSHOT_TESTING_RECORD` variable in the script, or `@Suite(.snapshots(record: .never))`), so a missing reference fails instead of being recorded. Under XCTest, image differences are captured as attachments, and the failure message names the files.

Baseline updates are a review decision. When a snapshot fails, keep the reference, the new image and the difference, and report whether the change matches the requested UI change. A reviewer approves the new baseline; only then does someone re-record with `record: .all` and commit it. The README adds one rule: compare on the exact same Simulator that recorded the reference, so pin one device model and runtime for everyone. Committed references then show up in the pull request as image diffs, side by side, as a swipe or as an onion skin ([GitHub Docs](https://docs.github.com/en/repositories/working-with-files/using-files/working-with-non-code-files)).

## How do I put the images in the pull request?

The REST endpoint that creates a pull request comment takes one field, the Markdown `body` ([GitHub REST API](https://docs.github.com/en/rest/issues/comments)), so an image has to live somewhere GitHub can render it.

| Route | What the reviewer sees | Watch out for |
| --- | --- | --- |
| Commit reference images to the pull request branch | Image diffs under Files changed | Every re-record adds PNGs to the history |
| Commit screenshots to a separate branch and link them from a comment | Inline images, linked as `../blob/<branch>/<file>.png?raw=true` | In a private repository, only people with read access see them |
| Upload a workflow artifact with `actions/upload-artifact@v7` | A download link from the `artifact-url` output | Viewers must be signed in; artifacts expire after 90 days by default |
| `gh pr comment --attach` | Inline images in a comment, hosted by GitHub | Needs GitHub CLI 2.99.0 or later and push access |

The relative `?raw=true` link is GitHub's documented form for images from the same repository in comments ([GitHub Docs](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax)). Artifact details come from the [upload-artifact README](https://github.com/actions/upload-artifact).

`--attach` arrived in GitHub CLI 2.99.0 on 1 Sep 2026 ([release notes](https://github.com/cli/cli/releases/tag/v2.99.0)). It uploads local images and videos, appends them to the comment, and takes up to 50 files per command; text after `#` becomes the alt text ([gh pr comment](https://cli.github.com/manual/gh_pr_comment)). Post the files the script selected:

```bash
gh --version        # 2.99.0 or later
gh auth status      # signed in; the token is not printed
RUN=build/evidence/<run>
gh pr comment 412 \
  --body "Settings screen after this change, light and dark." \
  --attach "$RUN/settings-light.png#Settings in light mode" \
  --attach "$RUN/settings-dark.png#Settings in dark mode"
```

GitHub asks for push access ([Attaching files with GitHub CLI](https://docs.github.com/en/github-cli/github-cli/attaching-files-with-github-cli)), caps images at 10 MB, and shows uploads in a private repository only to people with access ([Attaching files](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files)). Since [2.103.2](https://github.com/cli/cli/releases/tag/v2.103.2), GitHub's upload endpoint, not the CLI, decides whether a GitHub App installation token may attach files, so test with the token your automation uses.

## How does a coding agent run it?

Agentic iOS testing means the agent runs the test loop itself and returns evidence, not a claim. Give it the reviewed script, and keep publishing a separate step. [iOS Simulator for coding agents](https://macswarm.com/ios-simulator-for-coding-agents/) describes the loop on a hosted Mac; the rules below make it repeatable.

```markdown
## UI evidence
- After changing a screen, run scripts/ui-evidence.sh with the UDID named in this file.
- Look at build/evidence/<run>/settings-light.png and settings-dark.png before you report.
- If a snapshot test fails, keep the reference, the new image and the diff, and say whether
  the change matches the task. Do not re-record snapshots: a reviewer approves new baselines.
- Ask before posting. Then post with gh pr comment <number> --attach '<file>#<what it shows>'.
```

Codex reads `AGENTS.md` before doing any work ([OpenAI](https://learn.chatgpt.com/docs/agent-configuration/agents-md)). For Claude Code, import `@AGENTS.md` from the project's `CLAUDE.md` for an explicit path; current versions can also load `AGENTS.md` directly, subject to their instruction-file settings and parent or local files ([Anthropic](https://code.claude.com/docs/en/memory)).

Check the environment before the first run:

```bash
xcode-select -p                            # the Xcode the tools use
xcodebuild -version
xcrun simctl list runtimes available       # at least one iOS runtime
xcodebuild -list -project MyApp.xcodeproj  # the scheme exists
gh --version && gh auth status             # only for the publishing step
```

An agent sandbox can block what the script needs: the Simulator service, network access for packages and `gh`, and writes to DerivedData outside the repository. We have not tested either agent's sandbox against those, so run the script once by hand in the agent's environment, then allow only that script. `claude -p` accepts rules such as `--allowedTools "Bash(scripts/ui-evidence.sh *),Read"` ([Anthropic](https://code.claude.com/docs/en/headless)). `codex exec` starts read-only, and `--sandbox workspace-write` allows edits but keeps the network off until you enable it ([OpenAI](https://learn.chatgpt.com/docs/non-interactive-mode), [sandbox settings](https://learn.chatgpt.com/docs/agent-approvals-security)). Do not switch the sandbox off to make it pass.

## Can I run this on a Macswarm Pod?

Macswarm is serverless Mac infrastructure for Claude Code and Codex. Free, Pro and Team run Pods on shared Apple silicon; Enterprise reserves Macs. A Pod provides the build and Simulator side of this page: the Latest Xcode, iOS runtimes, three to four pre-booted Simulators and XcodeBuildMCP ([Inside a Pod](https://macswarm.com/docs/inside-a-pod/)), with Simulators leased to one agent at a time ([Limits and queues](https://macswarm.com/docs/limits-and-queues/)). The `gh --attach` route also needs a compatible GitHub CLI and an authorized git-host credential. The Pod inventory does not list the GitHub CLI, so use the commit routes until you have verified it in your Pod, or ask [info@macswarm.com](mailto:info@macswarm.com).

Pod-local data is destroyed with the Pod, ten minutes after its last session ends, so files left only on the Pod disappear. Logs and screenshots shown in the Macswarm dashboard are kept for 30 days in the region you selected, EU (Netherlands) or US, or a shorter period on request for Team and Enterprise ([privacy policy](https://macswarm.com/legal/privacy/)). Copies posted to GitHub follow GitHub's retention and access controls. To connect an agent, see the [Claude Code quickstart](https://macswarm.com/docs/quickstart-claude-code/) or the [Codex quickstart](https://macswarm.com/docs/quickstart-codex/); the Free plan includes 3 Pod-hours, no card.

## What else do people ask?

### Can GitHub Actions show iOS screenshots inline in a pull request?

Not from an artifact alone. `actions/upload-artifact` returns an `artifact-url` that downloads the file for signed-in users, and artifacts expire after 90 days by default. To show images inline, attach them with `gh pr comment --attach` from the workflow, or commit them and link them in a comment. GitHub's upload endpoint decides whether a workflow's GitHub App token may attach files, so test it with your token.

### Can the gh CLI upload images to a pull request comment?

Yes, since GitHub CLI 2.99.0, released on 1 September 2026. `gh pr comment --attach` uploads images and videos, up to 50 files per command, and needs push access to the repository. Images can be up to 10 MB. Earlier versions, and the REST endpoint that creates comments, accept Markdown text only, so images had to be hosted somewhere else and linked.

### Why are XCTAttachment screenshots missing after a passing test?

An attachment's lifetime defaults to `deleteOnSuccess`, which discards it when its test passes. Set `lifetime` to `keepAlways` on every screenshot you want to keep, run the tests with a new `-resultBundlePath`, and export them with `xcrun xcresulttool export attachments`.

### Why do snapshot tests fail on another Mac?

swift-snapshot-testing compares images, and its README says snapshots must be compared on the exact same simulator that took the reference. Pin one device model and iOS runtime for everyone, people and agents, record the references in that environment, and run verification with `SNAPSHOT_TESTING_RECORD` set to `never` so a missing reference fails instead of being recorded.

### What is agentic iOS testing?

A coding agent such as Claude Code or Codex runs the test loop itself: it builds the app, runs its UI and snapshot tests on an iOS Simulator, looks at the screenshots and reports what changed with the evidence attached. A reviewer still approves new snapshot baselines. It needs a Mac with Xcode and a Simulator, either your own or a hosted one such as a Macswarm Pod.
