Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

90 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Screenshotbot + Tuist example

A small SwiftUI chat app whose screens are covered by swift-snapshot-testing snapshots, wired up to Screenshotbot. The Xcode project is not checked in — it is generated by Tuist from Project.swift.

If you already have a Tuist project, the short version is: nothing about Screenshotbot changes. Snapshots are written next to your test sources, so the recorder uploads them the same way it would from a hand-maintained .xcodeproj. The only new requirement is that CI runs tuist install && tuist generate before it builds.

Getting started

mise install                # installs the Tuist version pinned in .mise.toml
tuist install               # resolves the Swift package dependencies
tuist generate              # writes SimpleProject.xcworkspace and opens it

mise will ask you to mise trust this repo the first time. If you'd rather not use mise, any Tuist 4.x install works.

Run the tests from the AllTests scheme in Xcode, or:

bundle exec fastlane tests          # tuist test, with selective testing

Re-record every snapshot with RECORD_SNAPSHOTS=1 in the test action's environment (see the header of SimpleProjectTests/SnapshotSupport.swift).

How the pieces fit together

File Role
Tuist.swift Project-wide Tuist configuration.
Project.swift The app, every test target, and the schemes. This replaces SimpleProject.xcodeproj.
Tuist/Package.swift External Swift package dependencies — here, Screenshotbot's fork of swift-snapshot-testing.
.mise.toml Pins the Tuist version so every machine and CI runner generates the same project.
fastlane/Fastfile generatetests → upload to Screenshotbot.
.circleci/config.yml CircleCI: installs mise, then runs the screenshotbot_ci lane.
ci_scripts/ The equivalent hooks for Xcode Cloud.

SimpleProject.xcodeproj, SimpleProject.xcworkspace, Derived/ and Tuist/.build are all generated, and all git-ignored.

Dependencies

Screenshotbot uses a fork of swift-snapshot-testing that does not fail a test run when a reference image is missing — comparison happens on Screenshotbot, not on the CI machine. With Tuist it's declared in Tuist/Package.swift:

.package(url: "https://github.com/tdrhq/swift-snapshot-testing", branch: "main"),

and consumed by the test target in Project.swift as .external(name: "SnapshotTesting").

Test targets

Twelve of them, laid out so the interesting cases are all represented:

Targets Snapshots? Tagged? Reaches SnapshotTesting
SimpleProjectTests yes no directly
InboxSnapshotTests, ThreadSnapshotTests, BubbleSnapshotTests, ComponentsSnapshotTests, OnboardingSnapshotTests yes screenshotbot through SnapshotSupport
ChatModelsTests, ChatClockTests, SampleDataTests, StringsTests, PaletteTests no no not at all
SimpleProjectUITests no no not at all

Each snapshot target writes its own __Snapshots__ directory and therefore becomes its own Screenshotbot channel, while the five plain unit test targets produce nothing to upload — so a run where only those changed uploads nothing at all.

The SnapshotSupport framework is the point of the middle row: real multi-module apps wrap the snapshot library in their own helpers, so tooling that classifies targets has to walk the dependency graph transitively rather than look for a direct edge. Tagging with TargetMetadata is the explicit alternative:

metadata: .metadata(tags: ["screenshotbot"])

SimpleProjectTests is deliberately left untagged, so both routes — inference and declaration — are exercised.

Schemes

Tuist doesn't autogenerate a scheme for test targets, and nothing in a Tuist repo should depend on Xcode autocreating one. Project.swift declares them explicitly; AllTests covers every unit test target and is the scheme CI runs. One scheme rather than twelve is deliberate — selective testing skips per target within a run, so this is what makes partial skips visible.

CI

Both CI setups do the same three things: install Tuist, generate the project, then run and upload the snapshots.

CircleCI (.circleci/config.yml + fastlane/Fastfile) — the setup command installs mise and the pinned Tuist, and the screenshotbot_ci lane runs:

lane :screenshotbot_ci do
  run_tuist "install"    # resolve the Swift package dependencies
  tests                  # tuist test — generates, then runs, AllTests
  fetch_screenshotbot    # install the recorder
  screenshotbot_upload_all
end

tuist test produces an .xcresult rather than the JUnit XML fastlane's scan used to emit, so CircleCI keeps the result bundle as a build artifact instead of feeding store_test_results.

Xcode Cloud (ci_scripts/) — ci_post_clone.sh installs Tuist and generates the workspace before the build starts; ci_post_xcodebuild.sh uploads the .xcresult to Screenshotbot. In the Xcode Cloud UI, point the workflow at SimpleProject.xcworkspace and the AllTests scheme — both exist by the time ci_post_clone.sh finishes.

Selective testing

The tests lane runs tuist test rather than xcodebuild test. Tuist hashes each target — sources, resources, settings, dependencies, manifest — and skips test targets whose hash matches an earlier successful run:

tuist test AllTests --device "iPhone SE (3rd generation)"
tuist test AllTests --no-selective-testing   # force a full run

Two passes

Selective testing and snapshot testing want opposite things, so the lane runs tuist test twice:

snapshot_targets = snapshot_test_targets     # read out of the Tuist graph

# 1. everything else, selectively
run_tuist "test AllTests … " + snapshot_targets.map { |t| "--skip-test-targets #{t}" }.join(" ")

# 2. the snapshot targets, always
run_tuist "test AllTests --no-selective-testing … " + snapshot_targets.map { |t| "--test-targets #{t}" }.join(" ")

Snapshot targets always run, so every channel gets screenshots on every commit and no marker bookkeeping is needed. Everything else is skipped when unchanged, which is where the time was going anyway.

snapshot_test_targets doesn't hardcode a list — it shells out to tuist graph --format json and picks targets that are tagged screenshotbot or reach SnapshotTesting transitively, so adding a snapshot target to Project.swift is all it takes for CI to treat it correctly.

Two things to know if you adapt this:

  • The flags are repeated, not comma-separated. --test-targets A,B is parsed as one target name and fails with "The following targets were not found: A,B".
  • tuist graph constructs the full graph, so this costs one extra graph load per CI run on top of the two test invocations.

Those hashes are cached locally by default, which is no use to a CI runner that starts empty every time. Sharing them across machines is what the tuist.dev account is for — a fullHandle, plus a project token on CI:

tuist auth login
tuist organization create screenshotbot          # or use your personal handle
tuist project create screenshotbot/tuist-example
tuist project tokens create screenshotbot/tuist-example

The token goes into CircleCI's project-level environment variables as TUIST_TOKEN — not into .circleci/config.yml, which is committed.

You don't need any of this to use this repo. A project that declares a fullHandle refuses to generate unless the caller is authenticated against that tuist.dev project, which would mean requiring an account just to clone the sample and read it. So Tuist.swift takes the handle from the environment:

let fullHandle = Environment.fullHandle.getString(default: "")
let tuist = fullHandle.isEmpty ? Tuist() : Tuist(fullHandle: fullHandle)

The CircleCI job sets TUIST_FULL_HANDLE (Environment.fullHandle reads TUIST_FULL_HANDLE) next to its token. Everywhere else — a fresh clone, Xcode Cloud, a fork's PR — generation works offline and every test simply runs.

Selective testing is a Tuist feature, not a Screenshotbot requirement. Swap the lane back to xcodebuild test and the snapshot workflow below is unchanged.

Selective testing and Screenshotbot

A skipped target writes no snapshots. Since the snapshots aren't committed, a fresh CI clone starts with an empty __Snapshots__ directory — and uploading an empty directory tells Screenshotbot that every screenshot in the channel was deleted, not that nothing changed.

The two-pass lane above is what keeps that from happening: snapshot targets are never skipped, so every channel is uploaded on every commit. The upload lane still skips empty directories as a backstop, in case a snapshot target is excluded some other way.

The alternative — letting snapshot targets be skipped and telling Screenshotbot "this channel is unchanged from commit X" with --mark-unchanged-from — needs an anchor commit that the build system can't give you. Tuist's stored hashes are project-global, so a skip can be justified by a run from any ancestor, or from another branch entirely; the parent commit is not a safe assumption. Always running the snapshot targets sidesteps the whole question.

One Tuist-specific gotcha

Tuist checks Swift package dependencies out into Tuist/.build, and swift-snapshot-testing ships __Snapshots__ directories of its own. A naive "upload every __Snapshots__ directory" sweep would turn those into Screenshotbot channels, so the upload lane skips anything under Tuist/:

Dir.glob("../**/__Snapshots__/").reject { |dir| dir.include?("/Tuist/") }

Each remaining directory becomes one channel, named after the directory that contains it — SimpleProjectTests, InboxSnapshotTests, and so on, one channel per snapshot target.

Batching

Six snapshot targets means six channels, and without batching that's six separate build statuses per commit. --batch groups them:

sh "~/screenshotbot/recorder --channel #{channel_name} --batch sample-app " \
   "--directory #{dir} --recursive"

The batch name is shared across the per-channel invocations, and that's what ties them into one status. It matters more the more targets you have — a monorepo with a channel per feature module wants exactly one notification per commit, not forty.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages