diff --git a/.github/workflows/release-swift-client.yml b/.github/workflows/release-swift-client.yml
index 4d053d06..11f1159a 100644
--- a/.github/workflows/release-swift-client.yml
+++ b/.github/workflows/release-swift-client.yml
@@ -12,6 +12,11 @@ on:
- patch
- minor
- major
+ prerelease:
+ description: 'Create prerelease (beta tag)?'
+ required: false
+ default: true
+ type: boolean
concurrency:
group: sdk-release
@@ -19,7 +24,7 @@ concurrency:
jobs:
release:
- runs-on: macos-latest
+ runs-on: macos-26
permissions:
contents: write
id-token: write
@@ -35,9 +40,21 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '22.13.1'
+ registry-url: 'https://registry.npmjs.org'
- - name: Show Xcode version
- run: xcodebuild -version
+ - name: Upgrade npm for Trusted Publishers
+ run: npm install -g npm@11.5.1
+
+ - name: Install pnpm
+ run: |
+ npm install -g corepack@latest
+ corepack enable
+ corepack prepare pnpm@11.3.0 --activate
+
+ - name: Select Xcode version
+ run: |
+ sudo xcode-select -s /Applications/Xcode_26.6.app
+ xcodebuild -version
- name: Configure git
run: |
@@ -53,12 +70,8 @@ jobs:
id: current_version
working-directory: ./clients/swift
run: |
- # Extract the latest released semver entry. Ignore [Unreleased].
- CURRENT_VERSION=$(grep -m 1 -E '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md | sed 's/## \[\(.*\)\].*/\1/' || true)
- if [ -z "$CURRENT_VERSION" ]; then
- CURRENT_VERSION="0.0.0"
- fi
- echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
+ CURRENT_VERSION=$(node -p "require('./package.json').version")
+ echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
- name: Get previous release tag
id: previous_tag
@@ -69,33 +82,37 @@ jobs:
fi
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
- - name: Calculate new version
+ - name: Bump version
id: new_version
+ working-directory: ./clients/swift
run: |
- CURRENT="${{ steps.current_version.outputs.version }}"
-
- # Validate version format (X.Y.Z)
- if ! [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "Error: Invalid version format '$CURRENT'. Expected X.Y.Z"
- exit 1
+ if [ "${{ github.event.inputs.prerelease }}" == "true" ]; then
+ if [[ "${{ steps.current_version.outputs.version }}" == *-beta.* ]]; then
+ NEW_VERSION=$(npm version prerelease --preid=beta --no-git-tag-version | sed 's/v//')
+ else
+ NEW_VERSION=$(npm version pre${{ github.event.inputs.version_type }} --preid=beta --no-git-tag-version | sed 's/v//')
+ fi
+ else
+ NEW_VERSION=$(npm version ${{ github.event.inputs.version_type }} --no-git-tag-version | sed 's/v//')
fi
-
- IFS='.' read -r -a parts <<< "$CURRENT"
-
- case "${{ github.event.inputs.version_type }}" in
- major)
- NEW_VERSION="$((parts[0] + 1)).0.0"
- ;;
- minor)
- NEW_VERSION="${parts[0]}.$((parts[1] + 1)).0"
- ;;
- patch)
- NEW_VERSION="${parts[0]}.${parts[1]}.$((parts[2] + 1))"
- ;;
- esac
-
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=swift/v$NEW_VERSION" >> $GITHUB_OUTPUT
+ echo "spm_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
+
+ - name: Verify release tags are available
+ run: |
+ for tag in \
+ "${{ steps.new_version.outputs.tag }}" \
+ "${{ steps.new_version.outputs.spm_tag }}"
+ do
+ if git rev-parse "refs/tags/$tag" >/dev/null 2>&1; then
+ echo "Release tag $tag already exists"
+ exit 1
+ fi
+ done
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
- name: Generate changelog
continue-on-error: true
@@ -173,6 +190,47 @@ jobs:
working-directory: ./clients/swift
run: swift test
+ - name: Check preview CLI package
+ working-directory: ./clients/swift
+ run: pnpm run check
+
+ - name: Run stock preview capture test
+ working-directory: ./clients/swift
+ run: |
+ xcrun simctl shutdown all
+ DEVICE_UDID="$(xcrun simctl list devices available --json | node --input-type=module -e '
+ let input = "";
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", chunk => input += chunk);
+ process.stdin.on("end", () => {
+ let payload = JSON.parse(input);
+ let device = Object.entries(payload.devices)
+ .filter(([runtime]) => runtime.includes(".SimRuntime.iOS-"))
+ .flatMap(([, devices]) => devices)
+ .find(candidate => candidate.isAvailable !== false);
+ if (!device) process.exit(1);
+ process.stdout.write(device.udid);
+ });
+ ')"
+ xcrun simctl boot "$DEVICE_UDID"
+ xcrun simctl bootstatus "$DEVICE_UDID" -b
+ VIZZLY_SIMULATOR_UDID="$DEVICE_UDID" pnpm run test:previews:e2e
+
+ - name: Verify npm package version is unpublished
+ working-directory: ./clients/swift
+ run: |
+ if npm view @vizzly-testing/swift@${{ steps.new_version.outputs.version }} version >/dev/null 2>&1; then
+ echo "@vizzly-testing/swift@${{ steps.new_version.outputs.version }} is already published"
+ exit 1
+ fi
+
+ - name: Pack npm package
+ id: pack
+ working-directory: ./clients/swift
+ run: |
+ PACK_FILE=$(npm pack --ignore-scripts)
+ echo "file=$PACK_FILE" >> $GITHUB_OUTPUT
+
- name: Configure git identity
run: |
git config --local user.email "${{ secrets.GIT_USER_EMAIL }}"
@@ -180,11 +238,26 @@ jobs:
- name: Commit and push changes
run: |
- git add clients/swift/CHANGELOG.md
+ git add clients/swift/package.json clients/swift/CHANGELOG.md
git commit -m "🔖 Swift client v${{ steps.new_version.outputs.version }}"
- git push origin main
git tag "${{ steps.new_version.outputs.tag }}"
- git push origin "${{ steps.new_version.outputs.tag }}"
+ git tag "${{ steps.new_version.outputs.spm_tag }}"
+ git push --atomic origin \
+ main \
+ "${{ steps.new_version.outputs.tag }}" \
+ "${{ steps.new_version.outputs.spm_tag }}"
+
+ - name: Publish preview CLI package to npm
+ working-directory: ./clients/swift
+ run: |
+ npm config delete //registry.npmjs.org/:_authToken 2>/dev/null || true
+ rm -f ~/.npmrc 2>/dev/null || true
+ npm config set registry https://registry.npmjs.org/
+ if [ "${{ github.event.inputs.prerelease }}" == "true" ]; then
+ npm publish "${{ steps.pack.outputs.file }}" --provenance --access public --tag beta
+ else
+ npm publish "${{ steps.pack.outputs.file }}" --provenance --access public
+ fi
- name: Read changelog for release
id: release_notes
@@ -204,7 +277,8 @@ jobs:
tag_name: ${{ steps.new_version.outputs.tag }}
name: 📱 Swift SDK v${{ steps.new_version.outputs.version }}
body: ${{ steps.release_notes.outputs.notes }}
+ files: ./clients/swift/${{ steps.pack.outputs.file }}
draft: false
- prerelease: false
+ prerelease: ${{ github.event.inputs.prerelease }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/sdk-unit.yml b/.github/workflows/sdk-unit.yml
index ea55e039..5f15859f 100644
--- a/.github/workflows/sdk-unit.yml
+++ b/.github/workflows/sdk-unit.yml
@@ -259,7 +259,8 @@ jobs:
# Swift SDK - uses the Xcode version configured by the hosted runner
swift:
name: Swift SDK
- runs-on: macos-latest
+ # Xcode 16.2 and 16.4 are both installed on this pinned image.
+ runs-on: macos-15
timeout-minutes: 8
needs: changes
if: needs.changes.outputs.swift == 'true'
diff --git a/Package.swift b/Package.swift
index 7a3f8dd3..f3e9ef91 100644
--- a/Package.swift
+++ b/Package.swift
@@ -16,6 +16,10 @@ let package = Package(
.library(
name: "VizzlyXCTest",
targets: ["VizzlyXCTest"]),
+ .library(
+ name: "VizzlyPreviewRuntime",
+ type: .dynamic,
+ targets: ["VizzlyPreviewRuntime"]),
],
targets: [
.target(
@@ -26,6 +30,15 @@ let package = Package(
name: "VizzlyXCTest",
dependencies: ["Vizzly"],
path: "clients/swift/Sources/VizzlyXCTest"),
+ .target(
+ name: "CVizzlyPreviewRuntime",
+ dependencies: [],
+ path: "clients/swift/Sources/CVizzlyPreviewRuntime",
+ publicHeadersPath: "include"),
+ .target(
+ name: "VizzlyPreviewRuntime",
+ dependencies: ["CVizzlyPreviewRuntime"],
+ path: "clients/swift/Sources/VizzlyPreviewRuntime"),
.testTarget(
name: "VizzlyTests",
dependencies: ["Vizzly", "VizzlyXCTest"],
diff --git a/README.md b/README.md
index 58d2cb73..5c8231b4 100644
--- a/README.md
+++ b/README.md
@@ -180,6 +180,17 @@ Or upload an existing folder of screenshots:
vizzly upload ./screenshots --threshold 2 --min-cluster-size 4 --batch-size 10 --upload-timeout 60000
```
+For iOS apps, the Swift plugin can render the stock SwiftUI `#Preview`
+declarations already in the app target:
+
+```bash
+pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta
+pnpm exec vizzly previews
+```
+
+See the [SwiftUI preview guide](clients/swift/PREVIEWS.md) for the supported
+Xcode and Simulator setup.
+
`--batch-size` controls how many screenshots are uploaded per request.
`--upload-timeout` controls the upload client's timeout, including how long
`--wait` polls for build processing.
@@ -237,6 +248,7 @@ export default {
| `vizzly run "cmd"` | Run tests with cloud build and review integration. |
| `vizzly context ...` | Fetch visual context for builds, comparisons, screenshots, and review queues. |
| `vizzly upload
` | Upload an existing folder of screenshots. |
+| `vizzly previews [container]` | Render and upload stock SwiftUI previews. |
| `vizzly preview ` | Upload static build output for in-context review. |
| `vizzly approve ` | Approve a visual comparison. |
| `vizzly reject ` | Reject a visual comparison with a reason. |
diff --git a/clients/swift/.gitignore b/clients/swift/.gitignore
index 70e1b1c4..f6a89192 100644
--- a/clients/swift/.gitignore
+++ b/clients/swift/.gitignore
@@ -1,6 +1,8 @@
# Swift Package Manager
.build/
*.xcodeproj
+!Fixtures/PreviewFixture/PreviewFixture.xcodeproj/
+!Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj
.swiftpm/
# Xcode
diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md
index f9cde6ad..ca8a1442 100644
--- a/clients/swift/CHANGELOG.md
+++ b/clients/swift/CHANGELOG.md
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Added a `vizzly previews` plugin and native Simulator runtime
+ that render existing stock SwiftUI `#Preview` declarations without Xcode MCP.
+- Added an iOS fixture that exercises app-module discovery, a named asset,
+ linked-runtime capture, preview traits, isolated failures, PNG output, and
+ manifest generation.
+- Added a dynamic `VizzlyPreviewRuntime` Swift Package product that Xcode builds,
+ embeds, and signs as part of the app target.
+- Added conservative booted iOS Simulator detection, with an explicit choice
+ required when more than one Simulator is booted.
+- Added conservative Xcode scheme detection, repeatable managed output, a
+ per-preview capture timeout, and clearer unsupported-preview failures.
+- Added npm packaging, CI checks, and release publishing for the Swift preview
+ CLI plugin.
+- Added automatic local TDD delivery for rendered preview PNGs, including
+ comparison metadata for the Simulator, viewport, SwiftUI view, Xcode, and
+ scheme.
+- Added cloud build creation, screenshot upload, flush, finalization, and build
+ URL reporting through the stable Vizzly plugin API.
+- Added `--no-upload`, local-only fallback, and upload outcomes in the preview
+ manifest.
+- Added fixed-layout and portrait or landscape trait rendering with exact
+ output dimensions.
+- Added per-preview failure isolation. Successful screenshots are kept and
+ uploaded before an incomplete capture exits with a failure.
+- Added `VizzlyPreviewRuntime.isCapturing` so apps can skip unsafe or unwanted
+ startup services during preview launches.
+
+### Fixed
+
+- Replaced CLI-side runtime compilation, app-bundle mutation, ad hoc re-signing,
+ and `DYLD_INSERT_LIBRARIES` with a normal Swift Package integration.
+- Fixed app executable discovery when Xcode does not emit a debug dylib.
+- Fixed Swift preview configuration so command options only override values
+ explicitly provided in `vizzly.config.js`.
+- Fixed the Simulator runtime's platform and scene lifecycle boundaries.
+- Fixed preview upload discovery for the TDD daemon's serialized port format
+ and normalized stock preview names for Vizzly's screenshot contract.
+- Fixed managed output validation so missing or duplicate preview files are
+ never treated as safe to replace.
+- Fixed preview uploads so both supported `VIZZLY_FAIL_ON_DIFF` values, `true`
+ and `1`, behave consistently.
+
## [0.1.0] - 2026-06-01
### What's Changed
diff --git a/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj b/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..cad203ce
--- /dev/null
+++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj
@@ -0,0 +1,223 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {};
+ objectVersion = 77;
+ objects = {
+
+ A10000000000000000000001 /* PreviewFixtureApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* PreviewFixtureApp.swift */; };
+ A10000000000000000000012 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000013 /* Assets.xcassets */; };
+ A10000000000000000000014 /* VizzlyPreviewRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000015 /* VizzlyPreviewRuntime */; };
+ A10000000000000000000017 /* VizzlyPreviewRuntime in Embed Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000015 /* VizzlyPreviewRuntime */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ A10000000000000000000002 /* PreviewFixtureApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewFixtureApp.swift; sourceTree = ""; };
+ A10000000000000000000003 /* PreviewFixture.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PreviewFixture.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ A10000000000000000000013 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+
+ A10000000000000000000018 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ A10000000000000000000017 /* VizzlyPreviewRuntime in Embed Frameworks */,
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+
+ A10000000000000000000004 = {
+ isa = PBXGroup;
+ children = (
+ A10000000000000000000005 /* PreviewFixture */,
+ A10000000000000000000006 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A10000000000000000000005 /* PreviewFixture */ = {
+ isa = PBXGroup;
+ children = (
+ A10000000000000000000002 /* PreviewFixtureApp.swift */,
+ A10000000000000000000013 /* Assets.xcassets */,
+ );
+ path = PreviewFixture;
+ sourceTree = "";
+ };
+ A10000000000000000000006 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A10000000000000000000003 /* PreviewFixture.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+
+ A10000000000000000000007 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000001 /* PreviewFixtureApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A10000000000000000000008 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000014 /* VizzlyPreviewRuntime in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A10000000000000000000009 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000012 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+
+ A1000000000000000000000A /* PreviewFixture */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A1000000000000000000000B /* Build configuration list for PBXNativeTarget "PreviewFixture" */;
+ buildPhases = (
+ A10000000000000000000007 /* Sources */,
+ A10000000000000000000008 /* Frameworks */,
+ A10000000000000000000018 /* Embed Frameworks */,
+ A10000000000000000000009 /* Resources */,
+ );
+ buildRules = ();
+ dependencies = ();
+ name = PreviewFixture;
+ packageProductDependencies = (
+ A10000000000000000000015 /* VizzlyPreviewRuntime */,
+ );
+ productName = PreviewFixture;
+ productReference = A10000000000000000000003 /* PreviewFixture.app */;
+ productType = "com.apple.product-type.application";
+ };
+
+ A1000000000000000000000C /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2660;
+ LastUpgradeCheck = 2660;
+ TargetAttributes = {
+ A1000000000000000000000A = { CreatedOnToolsVersion = 26.6; };
+ };
+ };
+ buildConfigurationList = A1000000000000000000000D /* Build configuration list for PBXProject "PreviewFixture" */;
+ compatibilityVersion = "Xcode 16.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (en, Base);
+ mainGroup = A10000000000000000000004;
+ packageReferences = (
+ A10000000000000000000016 /* XCLocalSwiftPackageReference "../.." */,
+ );
+ productRefGroup = A10000000000000000000006 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (A1000000000000000000000A /* PreviewFixture */);
+ };
+
+ A1000000000000000000000E /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ A1000000000000000000000F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ A10000000000000000000010 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = PreviewFixture;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = dev.vizzly.PreviewFixture;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 1;
+ };
+ name = Debug;
+ };
+ A10000000000000000000011 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = PreviewFixture;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = dev.vizzly.PreviewFixture;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 1;
+ };
+ name = Release;
+ };
+
+ A1000000000000000000000B /* Build configuration list for PBXNativeTarget "PreviewFixture" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (A10000000000000000000010 /* Debug */, A10000000000000000000011 /* Release */);
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A1000000000000000000000D /* Build configuration list for PBXProject "PreviewFixture" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (A1000000000000000000000E /* Debug */, A1000000000000000000000F /* Release */);
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A10000000000000000000016 /* XCLocalSwiftPackageReference "../.." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../..;
+ };
+ A10000000000000000000015 /* VizzlyPreviewRuntime */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A10000000000000000000016 /* XCLocalSwiftPackageReference "../.." */;
+ productName = VizzlyPreviewRuntime;
+ };
+ };
+ rootObject = A1000000000000000000000C /* Project object */;
+}
diff --git a/clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/Contents.json b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..74d6a722
--- /dev/null
+++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/PreviewAccent.colorset/Contents.json b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/PreviewAccent.colorset/Contents.json
new file mode 100644
index 00000000..a3bcbea1
--- /dev/null
+++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/PreviewAccent.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "srgb",
+ "components": {
+ "alpha": "1.000",
+ "blue": "0.900",
+ "green": "0.420",
+ "red": "0.180"
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift
new file mode 100644
index 00000000..b0b08c1c
--- /dev/null
+++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift
@@ -0,0 +1,77 @@
+import SwiftUI
+import VizzlyPreviewRuntime
+
+struct PreviewCard: View {
+ let title: String
+
+ var body: some View {
+ ZStack {
+ LinearGradient(
+ colors: [Color("PreviewAccent"), .indigo],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ .ignoresSafeArea()
+
+ VStack(spacing: 16) {
+ Image(systemName: "sparkles")
+ .font(.system(size: 48, weight: .semibold))
+ Text(title)
+ .font(.largeTitle.bold())
+ Text("Rendered from the app's existing #Preview")
+ .foregroundStyle(.secondary)
+ }
+ .padding(30)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28))
+ .padding(24)
+ }
+ }
+}
+
+struct StatefulCounter: View {
+ @State private var count = 7
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Text("Count: \(count)")
+ .font(.largeTitle.monospacedDigit())
+ Button("Increment") {
+ count += 1
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+}
+
+@main
+struct PreviewFixtureApp: App {
+ init() {
+ VizzlyPreviewRuntime.install()
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ Text("Ordinary app root")
+ }
+ }
+}
+
+#Preview("Card / Dark") {
+ PreviewCard(title: "Stock #Preview")
+ .preferredColorScheme(.dark)
+}
+
+#Preview("Stateful Counter") {
+ StatefulCounter()
+}
+
+#Preview(
+ "Fixed Layout",
+ traits: .fixedLayout(width: 320, height: 200)
+) {
+ Text("This preview verifies trait rendering")
+}
+
+#Preview("Unsupported Size That Fits", traits: .sizeThatFitsLayout) {
+ Text("This preview verifies isolated failures")
+}
diff --git a/clients/swift/INTEGRATION.md b/clients/swift/INTEGRATION.md
index 6e846c56..7c337d6d 100644
--- a/clients/swift/INTEGRATION.md
+++ b/clients/swift/INTEGRATION.md
@@ -1,482 +1,135 @@
-# iOS Integration Guide
+# XCTest integration guide
-Complete guide for adding Vizzly to your iOS app's UI tests.
+The `VizzlyXCTest` product adds screenshot helpers to `XCUIApplication`,
+`XCUIElement`, and `XCTestCase`. It supports iOS 13+ and macOS 10.15+ UI tests.
-## Step-by-Step Integration
+Start with [QUICKSTART.md](QUICKSTART.md) if you have not captured a local
+screenshot yet.
-### 1. Install Vizzly CLI
+## Connection discovery
-The CLI provides the TDD server and cloud upload capabilities.
+`VizzlyClient` uses the first available screenshot server:
-```bash
-pnpm install -g @vizzly-testing/cli
-```
-
-### 2. Add Swift SDK to Your Project
-
-#### Option A: Swift Package Manager (Recommended)
-
-In Xcode:
-
-1. **File → Add Package Dependencies**
-2. Enter URL: `https://github.com/vizzly-testing/cli`
-3. Select version/branch
-4. Add the `VizzlyXCTest` product to your **UI Test target**
-
-Use the core `Vizzly` product directly only when you need to send PNG data from
-app or test-support code without the XCTest convenience extensions.
-
-#### Option B: Local Package
-
-If you're developing locally or testing changes:
-
-1. Clone the repo:
- ```bash
- git clone https://github.com/vizzly-testing/cli.git
- ```
-
-2. In Xcode:
- - **File → Add Packages → Add Local...**
- - Select `/path/to/cli/clients/swift`
- - Add to UI test target
-
-### 3. Initialize Vizzly in Your Project
-
-Navigate to your iOS project root:
+1. `VIZZLY_SERVER_URL`
+2. Project-local `.vizzly/server.json`
+3. User-level `.vizzly/server.json`
+4. A live server on `http://localhost:47392`
-```bash
-cd /path/to/MyiOSApp
-```
+`vizzly tdd start` writes the discovery file automatically. If the default port
+is busy, use the dashboard URL printed by the command.
-Create a `vizzly.config.js` file (optional but recommended):
+## Capture options
-```javascript
-import { defineConfig } from '@vizzly-testing/cli/config';
+Capture the full app:
-export default defineConfig({
- server: {
- port: 47392,
- },
- comparison: {
- // Delta E comparison threshold. Omitted screenshots use server config.
- threshold: 0,
- },
-});
+```swift
+app.vizzlyScreenshot(
+ name: "checkout",
+ properties: [
+ "theme": "dark",
+ "account": "premium"
+ ],
+ threshold: 1.5,
+ minClusterSize: 3,
+ requestTimeout: 60_000
+)
```
-### 4. Start TDD Server
+Capture one element:
-```bash
-vizzly tdd start --open
+```swift
+app.buttons["Buy"].vizzlyScreenshot(name: "buy-button")
```
-This starts a local server that will:
-- Receive screenshots from your tests
-- Compare them against baselines
-- Serve a dashboard at the URL printed by the command
+`threshold` is the CIEDE2000 Delta E threshold. `minClusterSize` ignores changed
+pixel clusters smaller than the given count. Leave either value out to use the
+server configuration.
-Vizzly uses port `47392` by default. If that port is busy, it auto-assigns
-another free port and prints that URL instead.
+Choose stable names. Add properties when the same screen has meaningful
+variants such as device class, theme, or signed-in state.
-For a one-off run, wrap your test command:
+## Stable screenshots
-```bash
-vizzly tdd run \
- "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'" \
- --no-open
-```
-
-That writes local review data under `.vizzly/` and creates a static report at
-`.vizzly/report/index.html` when screenshots are captured.
-
-### 5. Write UI Tests with Vizzly
-
-Create or update your UI test file:
+Wait for an observable UI state before capture:
```swift
-import XCTest
-import Vizzly
-import VizzlyXCTest
-
-final class MyAppUITests: XCTestCase {
-
- let app = XCUIApplication()
-
- override func setUpWithError() throws {
- continueAfterFailure = true
- app.launch()
-
- // Optional: Log Vizzly status
- print("Vizzly ready: \(VizzlyClient.shared.isReady)")
- }
-
- func testLaunchScreen() {
- // Wait for launch screen
- let logo = app.images["AppLogo"]
- XCTAssertTrue(logo.waitForExistence(timeout: 5))
-
- // Capture screenshot
- app.vizzlyScreenshot(name: "launch-screen")
- }
-
- func testHomeScreen() {
- // Wait for home screen
- let homeTitle = app.navigationBars["Home"]
- XCTAssertTrue(homeTitle.waitForExistence(timeout: 5))
-
- // Capture with properties
- app.vizzlyScreenshot(
- name: "home-screen",
- properties: [
- "section": "home",
- "authenticated": false
- ]
- )
- }
-}
+let loaded = app.otherElements["ProfileLoaded"]
+XCTAssertTrue(loaded.waitForExistence(timeout: 5))
+app.vizzlyScreenshot(name: "profile")
```
-### 6. Run Tests
+Do not use a fixed sleep to guess when the screen is ready. Disable animations,
+freeze dates, and seed test data when those values affect the pixels.
-#### Via Xcode
+## Fail on local differences
-1. Select your UI test scheme
-2. Choose a simulator/device
-3. Press `Cmd+U` or Product → Test
-
-#### Via Command Line
+Set either value before running the test:
```bash
-xcodebuild test \
+VIZZLY_FAIL_ON_DIFF=true xcodebuild test \
-scheme MyApp \
- -destination 'platform=iOS Simulator,name=iPhone 15' \
- -only-testing:MyAppUITests
-```
-
-### 7. Review Results
-
-Open the dashboard in your browser:
-
-```
-http://localhost:47392/dashboard
-```
-
-You'll see:
-- ✅ **Passed**: Screenshots that match baselines
-- ⚠️ **Failed**: Screenshots with visual differences
-- 🆕 **New**: First-time screenshots without baselines
-
-Click on any comparison to see side-by-side diffs, then accept or reject changes.
-
-## Project Structure
-
-Here's a recommended structure for your iOS project:
-
-```
-MyiOSApp/
-├── MyApp/ # Main app target
-│ ├── App/
-│ ├── Views/
-│ └── ...
-├── MyAppTests/ # Unit tests
-│ └── ...
-├── MyAppUITests/ # UI tests (add Vizzly here)
-│ ├── LaunchTests.swift
-│ ├── HomeScreenTests.swift
-│ └── CheckoutFlowTests.swift
-├── vizzly.config.js # Vizzly config (optional)
-├── .vizzly/ # Created by TDD server
-│ ├── baselines/ # Baseline screenshots
-│ ├── current/ # Current test screenshots
-│ ├── diffs/ # Diff images
-│ └── server.json # Server metadata
-└── .gitignore # Add .vizzly/current and .vizzly/diffs
-```
-
-## .gitignore Configuration
-
-Add these lines to your `.gitignore`:
-
-```gitignore
-# Vizzly - commit baselines, ignore current/diffs
-.vizzly/current/
-.vizzly/diffs/
-.vizzly/server.json
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
```
-**Important**: Commit `.vizzly/baselines/` so your team shares the same baseline screenshots.
-
-## Testing Multiple Devices
+`VIZZLY_FAIL_ON_DIFF=1` works too. You can also create a dedicated client with
+an explicit setting:
```swift
-// Run tests on different simulators to capture device-specific screenshots
-// Vizzly automatically includes device info in properties
-
-func testResponsiveDesign() {
- app.launch()
-
- // The SDK automatically captures:
- // - Device model (iPhone 15, iPad Air, etc.)
- // - Screen dimensions
- // - Scale factor
-
- app.vizzlyScreenshot(name: "home-screen")
-}
+let client = VizzlyClient(failOnDiff: true)
```
-Run tests on multiple simulators:
-
-```bash
-# iPhone 15
-xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'
+## Direct PNG uploads
-# iPhone 15 Pro Max
-xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15 Pro Max'
-
-# iPad Air
-xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPad Air (5th generation)'
-```
-
-Each device creates separate baselines due to different viewport metadata.
-
-## Dark Mode Testing
+Use the core `Vizzly` product when you already have PNG `Data` and do not need
+XCTest helpers:
```swift
-func testDarkMode() {
- app.launch()
-
- // Enable dark mode programmatically
- app.buttons["Settings"].tap()
- app.switches["Appearance"].tap() // Toggle to dark
-
- app.buttons["Done"].tap()
+let client = VizzlyClient(serverUrl: "http://localhost:47392")
- // Capture dark mode screenshot
- app.vizzlyScreenshot(
- name: "home-dark",
- properties: ["theme": "dark"]
- )
-}
+client.screenshot(
+ name: "rendered-card",
+ image: pngData,
+ properties: ["platform": "iOS"]
+)
```
-Or test both modes in one test:
+## Cloud CI
-```swift
-func testBothThemes() {
- app.launch()
-
- // Light mode
- app.vizzlyScreenshot(name: "home", properties: ["theme": "light"])
-
- // Switch to dark
- toggleDarkMode()
-
- // Dark mode
- app.vizzlyScreenshot(name: "home", properties: ["theme": "dark"])
-}
-```
-
-## Handling Animations
-
-For views with animations or timing-sensitive content:
-
-```swift
-func testAnimatedView() {
- app.launch()
-
- let finishedState = app.otherElements["AnimatedBannerReady"]
- XCTAssertTrue(finishedState.waitForExistence(timeout: 5))
-
- // Use a Delta E comparison threshold for slight visual variations
- app.vizzlyScreenshot(
- name: "animated-banner",
- threshold: 5
- )
-}
-```
-
-## CI/CD Integration
-
-### GitHub Actions
-
-Create `.github/workflows/visual-tests.yml`:
+Store `VIZZLY_TOKEN` as a CI secret, then wrap the real test command with
+`vizzly run --wait`:
```yaml
-name: Visual Regression Tests
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-jobs:
- ios-visual-tests:
- runs-on: macos-latest
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Select Xcode version
- run: sudo xcode-select -s /Applications/Xcode_15.0.app
-
- - name: Install Vizzly CLI
- run: pnpm install -g @vizzly-testing/cli
-
- - name: Run UI Tests with Vizzly
- env:
- VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
- run: |
- vizzly run "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:MyAppUITests"
+- name: Run visual UI tests
+ env:
+ VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
+ run: |
+ pnpm exec vizzly run \
+ "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:MyAppUITests" \
+ --wait
```
-### Fastlane
-
-Add to your `Fastfile`:
-
-```ruby
-lane :visual_tests do
- sh("pnpm exec vizzly run \"bundle exec fastlane scan scheme:MyApp devices:'iPhone 15' only_testing:MyAppUITests\"")
-end
-```
-
-## Advanced Patterns
-
-### Page Object Pattern
-
-```swift
-// Pages/HomePage.swift
-import XCTest
-
-class HomePage {
- let app: XCUIApplication
-
- init(app: XCUIApplication) {
- self.app = app
- }
-
- var title: XCUIElement {
- app.navigationBars["Home"]
- }
-
- var loginButton: XCUIElement {
- app.buttons["Login"]
- }
-
- func screenshot(name: String) {
- app.vizzlyScreenshot(
- name: "home-\(name)",
- properties: ["page": "home"]
- )
- }
-}
-
-// Test usage
-func testHomePage() {
- let homePage = HomePage(app: app)
-
- XCTAssertTrue(homePage.title.waitForExistence(timeout: 5))
- homePage.screenshot(name: "initial")
-
- homePage.loginButton.tap()
- // ... continue test
-}
-```
-
-### Component Testing
-
-```swift
-func testReusableComponents() {
- app.launch()
-
- // Test button variants
- for variant in ["primary", "secondary", "destructive"] {
- let button = app.buttons["\(variant)Button"]
-
- button.vizzlyScreenshot(
- name: "components-button-\(variant)",
- properties: [
- "component": "button",
- "variant": variant
- ]
- )
- }
-}
-```
+The CLI creates the cloud build, gives the Swift SDK its screenshot server and
+build ID, waits for processing, and returns the review result to CI.
## Troubleshooting
-### Tests Pass But No Screenshots Captured
-
-**Cause**: Vizzly server not running or not discoverable.
-
-**Solution**:
-
-1. Check server is running: `vizzly tdd status`
-2. If not, start it: `vizzly tdd start`
-3. Verify `.vizzly/server.json` exists in your project
-4. Add debug logging:
-
-```swift
-override func setUpWithError() throws {
- print("Vizzly info: \(VizzlyClient.shared.info)")
-}
-```
-
-### Screenshots Different on CI vs Local
-
-**Cause**: Different simulator versions, screen sizes, or font rendering.
-
-**Solution**:
-
-1. Pin simulator versions in CI to match local
-2. Use consistent device names
-3. Consider a slightly higher Delta E comparison threshold for font rendering differences
-
-### "Connection Refused" Errors
-
-**Cause**: TDD server not running or wrong port.
-
-**Solution**:
-
-```bash
-# Check if server is running
-vizzly tdd status
-
-# Check what's running on port 47392
-lsof -i :47392
-
-# Restart server
-vizzly tdd stop
-vizzly tdd start
-```
-
-### Server Not Found
-
-**Cause**: SDK cannot discover the running server.
+### The test passes but no screenshot appears
-**Solution**:
+- Run `pnpm exec vizzly tdd status`.
+- Check that `.vizzly/server.json` exists under the project.
+- Print `VizzlyClient.shared.info` from the test.
+- Make sure the Mac or Simulator can reach the server URL.
-1. Ensure TDD server is running: `vizzly tdd start`
-2. Check `.vizzly/server.json` exists in your project checkout
-3. Verify the printed server URL is reachable, for example:
- `curl http://localhost:47392/health`
-4. Or explicitly set the printed URL:
- `export VIZZLY_SERVER_URL=http://localhost:47392`
+The SDK skips screenshots after a connection failure so a local Vizzly outage
+does not break unrelated UI tests.
-## Best Practices
+### Local differences do not fail the test
-1. **Separate Visual Tests**: Keep visual regression tests in dedicated test files
-2. **Descriptive Names**: Use hierarchical names like `checkout-payment-valid-card` (use dashes, not slashes)
-3. **Wait for Content**: Always wait for elements before screenshotting
-4. **Commit Baselines**: Add `.vizzly/baselines/` to version control
-5. **Use Properties**: Tag screenshots with context (theme, user state, etc.)
-6. **Test Critical Flows**: Focus on user-facing screens and key journeys
-7. **Automate in CI**: Run visual tests on every PR
+Set `VIZZLY_FAIL_ON_DIFF=true`, or start TDD with its fail-on-diff option. Check
+`VizzlyClient.shared.info["failOnDiff"]` to confirm the resolved setting.
-## Next Steps
+### Screenshots are grouped incorrectly
-- Explore the [Example Tests](Example/ExampleUITests.swift) for more patterns
-- Read the [main README](README.md) for API reference
-- Check [Vizzly docs](https://docs.vizzly.dev) for cloud features
-- Join the community: https://github.com/vizzly-testing/cli/discussions
+Use a stable screenshot name and include device, theme, or state in
+`properties`. Vizzly already includes platform and viewport metadata for the
+XCTest helpers.
diff --git a/clients/swift/PREVIEWS.md b/clients/swift/PREVIEWS.md
new file mode 100644
index 00000000..d58446d8
--- /dev/null
+++ b/clients/swift/PREVIEWS.md
@@ -0,0 +1,276 @@
+# SwiftUI `#Preview` capture
+
+Vizzly renders the stock `#Preview` declarations already in your app. You do
+not need a Vizzly macro, a catalog, or a second set of preview definitions.
+
+## Requirements
+
+- Xcode 26.6
+- Node.js 22+
+- An arm64 Mac
+- An iOS 17+ Simulator
+- A scene-based iOS app
+- A shared Xcode scheme that builds the app in Debug
+
+The current renderer supports fixed layouts and portrait or landscape
+orientation traits. Other traits, including `sizeThatFitsLayout`, custom
+preview modifiers, and Assistive Access, fail that preview with a clear entry
+in the capture manifest.
+
+## Install
+
+Add the CLI and Swift plugin to the iOS project:
+
+```bash
+pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta
+```
+
+Then add this repository as a Swift Package dependency in Xcode:
+
+```text
+https://github.com/vizzly-testing/cli
+```
+
+For the beta, choose **Exact Version** and enter `0.1.1-beta.0`. This repository
+also contains the Vizzly CLI, so a broad version rule can select an unrelated
+CLI release tag.
+
+Add the dynamic `VizzlyPreviewRuntime` product to the app target and choose
+**Embed & Sign**. Install it once from the app initializer:
+
+```swift
+import SwiftUI
+import VizzlyPreviewRuntime
+
+@main
+struct MyApp: App {
+ init() {
+ VizzlyPreviewRuntime.install()
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
+```
+
+That is the complete app integration. Keep writing normal `#Preview`
+declarations. The runtime does nothing during an ordinary app launch and
+compiles to a no-op outside the iOS Simulator.
+
+## Keep capture launches safe
+
+Vizzly launches the built app once per preview. Your app initializer and some
+scene lifecycle code can run before Vizzly replaces the app window with the
+preview. The process has the same Simulator data and network access as an
+ordinary app launch.
+
+Use a dedicated development Simulator. Keep destructive startup work out of app
+initializers, and gate services that should not run during capture:
+
+```swift
+init() {
+ VizzlyPreviewRuntime.install()
+
+ if !VizzlyPreviewRuntime.isCapturing {
+ startProductionServices()
+ }
+}
+```
+
+The CLI only adds the preview registry and output filename to the launched app
+environment. It does not pass `VIZZLY_TOKEN` or other Vizzly credentials into
+the app process.
+
+## Capture previews
+
+Boot an iOS Simulator, then run this from a directory containing one Xcode
+project or workspace:
+
+```bash
+pnpm exec vizzly previews
+```
+
+Vizzly auto-selects a project, shared scheme, or booted Simulator only when
+there is exactly one choice. Pass ambiguous values explicitly:
+
+```bash
+pnpm exec vizzly previews MyApp.xcworkspace \
+ --scheme MyApp \
+ --device B40B976E-CD70-45F2-830C-48E8ED9B7EE7
+```
+
+Use `xcrun simctl list devices booted` to find the Simulator UDID.
+
+## Local review
+
+For one capture and report:
+
+```bash
+pnpm exec vizzly tdd run "pnpm exec vizzly previews" --no-open
+```
+
+If `vizzly tdd start` is already running in this project, plain
+`vizzly previews` finds its `.vizzly/server.json` file and sends screenshots to
+that server.
+
+## Cloud upload
+
+Set `VIZZLY_TOKEN` and run the same command. The plugin creates a cloud build,
+uploads every preview, finalizes the build, and prints the result URL.
+
+```bash
+VIZZLY_TOKEN=... pnpm exec vizzly previews --scheme MyApp
+```
+
+Upload routing is predictable:
+
+1. A live project-local TDD server wins.
+2. Otherwise, `VIZZLY_TOKEN` or `apiKey` creates a cloud build.
+3. Without either one, screenshots stay local.
+
+Pass `--no-upload` when local artifacts are the intended result.
+
+## Configuration
+
+Put shared defaults under `swiftPreviews` in `vizzly.config.js`:
+
+```javascript
+import { defineConfig } from '@vizzly-testing/cli/config';
+
+export default defineConfig({
+ swiftPreviews: {
+ scheme: 'MyApp',
+ device: 'B40B976E-CD70-45F2-830C-48E8ED9B7EE7',
+ configuration: 'Debug',
+ captureTimeout: 30_000,
+ output: '.vizzly/previews',
+ upload: true,
+ },
+});
+```
+
+Command options override the config file:
+
+- `--scheme `: shared Xcode scheme
+- `--device `: booted iOS Simulator
+- `--configuration `: build configuration
+- `--capture-timeout `: limit for each preview launch
+- `--output `: PNG and manifest directory
+- `--no-upload`: keep artifacts local
+- `--json`: print the manifest as JSON
+
+## Output
+
+The default output is `.vizzly/previews`:
+
+```text
+.vizzly/previews/
+├── 001-card-dark.png
+├── 002-stateful-counter.png
+└── manifest.json
+```
+
+The manifest records the Xcode version, scheme, Simulator, preview names,
+image dimensions, hashes, capture failures, and upload result. `upload.mode` is
+one of `tdd`, `cloud`, `local-only`, or `disabled`.
+
+Vizzly keeps rendering after one preview fails or times out. It saves and
+uploads successful captures, records each failure in `manifest.json`, then
+exits with a non-zero status so CI cannot mistake an incomplete run for a
+complete one.
+
+A successful rerun replaces an output directory previously created by Vizzly.
+If the directory has missing, changed, or unrelated files, Vizzly refuses to
+delete it.
+
+## CI
+
+Preview CI needs an arm64 macOS runner with Xcode 26.6 and a booted iOS
+Simulator. Keep the scheme shared in source control.
+
+```yaml
+- name: Boot Simulator
+ run: |
+ xcrun simctl boot "$VIZZLY_SIMULATOR_UDID"
+ xcrun simctl bootstatus "$VIZZLY_SIMULATOR_UDID" -b
+
+- name: Capture SwiftUI previews
+ env:
+ VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
+ VIZZLY_SIMULATOR_UDID: ${{ vars.VIZZLY_SIMULATOR_UDID }}
+ run: |
+ pnpm exec vizzly previews \
+ MyApp.xcodeproj \
+ --scheme MyApp \
+ --device "$VIZZLY_SIMULATOR_UDID"
+```
+
+`simctl bootstatus` waits for a concrete Simulator boot event; no fixed delay is
+needed.
+
+## Troubleshooting
+
+### More than one project, scheme, or Simulator is available
+
+Pass the project path, `--scheme`, or `--device`. Vizzly lists the ambiguous
+choices in the error.
+
+### No shared scheme is available
+
+In Xcode, choose **Product → Scheme → Manage Schemes**, mark the app scheme as
+shared, and commit the scheme file.
+
+### No booted Simulator is found
+
+Boot one from Xcode or Simulator. Confirm it appears under:
+
+```bash
+xcrun simctl list devices booted
+```
+
+### Xcode is unsupported
+
+Run `xcodebuild -version`. This release supports exactly Xcode 26.6 because the
+renderer depends on that release's Swift preview ABI.
+
+### No previews are found
+
+Make sure the selected scheme builds the app target containing the `#Preview`
+declarations in Debug. Vizzly looks in the app executable and debug dylibs.
+
+### VizzlyPreviewRuntime is not linked and embedded
+
+In the app target's **General** settings, confirm that
+`VizzlyPreviewRuntime.framework` appears under **Frameworks, Libraries, and
+Embedded Content** with **Embed & Sign** selected. Also confirm the app imports
+`VizzlyPreviewRuntime` and calls `VizzlyPreviewRuntime.install()` from its
+initializer.
+
+### The output directory is rejected
+
+Choose a new `--output` path, or move the existing directory yourself. Vizzly
+will not remove files it cannot prove it created.
+
+### One preview crashes
+
+Open `manifest.json` and check the failure's `registryType`. It contains the
+source filename and line used by the generated preview registry. A crash here
+usually means the preview body is missing an environment object or another
+dependency it also needs in Xcode's canvas.
+
+## How it works
+
+The CLI builds the real app for the selected Simulator, finds generated
+`DeveloperToolsSupport.PreviewRegistry` types in the Mach-O, and launches one
+fresh app process per preview. The normally linked native runtime captures the
+preview body, mounts it in the app window, and writes a PNG.
+
+This path does not use Xcode MCP, `mcpbridge`, private Xcode actions, or source
+rewriting. It also does not inject a library, copy code into the built app,
+change the app's signature, or pass credentials to the app process. Xcode owns
+the runtime's build, embedding, and signing like any other Swift Package
+dependency. The exact Xcode check is the safety boundary around the private
+Swift ABI used for preview discovery.
diff --git a/clients/swift/Package.swift b/clients/swift/Package.swift
index 5ab62e32..058430be 100644
--- a/clients/swift/Package.swift
+++ b/clients/swift/Package.swift
@@ -16,6 +16,10 @@ let package = Package(
.library(
name: "VizzlyXCTest",
targets: ["VizzlyXCTest"]),
+ .library(
+ name: "VizzlyPreviewRuntime",
+ type: .dynamic,
+ targets: ["VizzlyPreviewRuntime"]),
],
targets: [
.target(
@@ -24,6 +28,13 @@ let package = Package(
.target(
name: "VizzlyXCTest",
dependencies: ["Vizzly"]),
+ .target(
+ name: "CVizzlyPreviewRuntime",
+ dependencies: [],
+ publicHeadersPath: "include"),
+ .target(
+ name: "VizzlyPreviewRuntime",
+ dependencies: ["CVizzlyPreviewRuntime"]),
.testTarget(
name: "VizzlyTests",
dependencies: ["Vizzly", "VizzlyXCTest"]),
diff --git a/clients/swift/QUICKSTART.md b/clients/swift/QUICKSTART.md
index 06586cbd..08be8422 100644
--- a/clients/swift/QUICKSTART.md
+++ b/clients/swift/QUICKSTART.md
@@ -1,125 +1,66 @@
-# Vizzly Swift SDK - Quick Start
+# XCTest quick start
-Get visual regression testing in your iOS app in 5 minutes.
+This guide gets one iOS UI test into local Vizzly TDD.
-## 1. Install Vizzly CLI
+## 1. Install the CLI
+
+From your iOS project:
```bash
-pnpm install -g @vizzly-testing/cli
+pnpm add --save-dev @vizzly-testing/cli
```
-## 2. Add Swift SDK to Xcode
+## 2. Add the Swift package
-1. Open your iOS project in Xcode
-2. **File → Add Package Dependencies**
-3. Paste: `https://github.com/vizzly-testing/cli`
-4. Add the `VizzlyXCTest` product to your **UI Test target**
+In Xcode:
-## 3. Start TDD Server
+1. Choose **File → Add Package Dependencies**.
+2. Enter `https://github.com/vizzly-testing/cli`.
+3. Add `VizzlyXCTest` to the UI test target.
-In your iOS project directory:
+## 3. Start local TDD
```bash
-vizzly tdd start --open
+pnpm exec vizzly tdd start --open
```
-Vizzly uses port `47392` by default. If that port is busy, it prints the
-dashboard URL with the auto-assigned port.
+The command prints the dashboard URL. Keep it running while the UI test runs.
-## 4. Write a Visual Test
+## 4. Capture a screenshot
```swift
import XCTest
import Vizzly
import VizzlyXCTest
-class MyAppUITests: XCTestCase {
- let app = XCUIApplication()
-
+final class HomeScreenTests: XCTestCase {
func testHomeScreen() {
+ let app = XCUIApplication()
app.launch()
- // Wait for screen to load
let title = app.navigationBars["Home"]
XCTAssertTrue(title.waitForExistence(timeout: 5))
- // 📸 Capture screenshot
- app.vizzlyScreenshot(name: "home-screen")
+ app.vizzlyScreenshot(name: "home")
}
}
```
-## 5. Run Tests
+Run the test with `Cmd+U` or `xcodebuild`. The screenshot appears in the local
+dashboard.
-Press `Cmd+U` in Xcode, or:
+For a one-off run, let Vizzly own the server lifecycle:
```bash
-xcodebuild test \
- -scheme MyApp \
- -destination 'platform=iOS Simulator,name=iPhone 15'
-```
-
-## 6. Review Results
-
-Open the dashboard URL printed by `vizzly tdd start`.
-
-- ✅ Green = Screenshots match baselines
-- ⚠️ Yellow = Visual differences detected
-- 🆕 Blue = New screenshots (first run)
-
-Click any screenshot to see side-by-side comparison and approve/reject changes.
-
-For a one-off local check, wrap the test command instead:
-
-```bash
-vizzly tdd run \
- "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'" \
+pnpm exec vizzly tdd run \
+ "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro'" \
--no-open
```
-That writes review data under `.vizzly/` and creates `.vizzly/report/index.html`
-when screenshots are captured.
-
-## Next Steps
-
-- **More Examples**: See [Example/ExampleUITests.swift](Example/ExampleUITests.swift)
-- **Full Docs**: Read [README.md](README.md)
-- **Integration Guide**: Check [INTEGRATION.md](INTEGRATION.md) for CI/CD, dark mode, multiple devices
-- **Website**: https://vizzly.dev
-
-## Common API Usage
-
-### Screenshot with Properties
-
-```swift
-app.vizzlyScreenshot(
- name: "checkout-flow",
- properties: [
- "theme": "dark",
- "user": "premium"
- ]
-)
-```
-
-### Screenshot an Element
-
-```swift
-let button = app.buttons["Submit"]
-button.vizzlyScreenshot(name: "submit-button")
-```
-
-### Custom Threshold
-
-```swift
-// Allow a higher comparison threshold for animated content
-app.vizzlyScreenshot(
- name: "animated-view",
- threshold: 5
-)
-```
+Vizzly writes the static report to `.vizzly/report/index.html`.
-## Questions?
+## Next steps
-- **Docs**: https://docs.vizzly.dev
-- **GitHub**: https://github.com/vizzly-testing/cli
-- **Support**: support@vizzly.dev
+- [XCTest options and CI](INTEGRATION.md)
+- [Stock SwiftUI preview capture](PREVIEWS.md)
+- [Complete UI test example](Example/ExampleUITests.swift)
diff --git a/clients/swift/README.md b/clients/swift/README.md
index 36e5b0d6..5323e488 100644
--- a/clients/swift/README.md
+++ b/clients/swift/README.md
@@ -1,532 +1,120 @@
# Vizzly Swift SDK
-A lightweight Swift SDK for capturing screenshots from iOS and macOS UI tests and sending them to Vizzly for visual regression testing.
+Vizzly brings visual testing to Swift in two ways:
-Unlike tools that render components in isolation, Vizzly captures screenshots directly from your **real UI tests**. Test your actual app, get visual regression testing for free.
+| Workflow | Use it for | Runs on |
+| --- | --- | --- |
+| SwiftUI previews | Render the stock `#Preview` declarations already in your app | arm64 iOS Simulator |
+| XCTest screenshots | Capture an app or element during a UI test | iOS or macOS |
-## Features
+Both workflows send screenshots to the same local TDD and cloud review tools.
+You can use either one or both.
-- **Zero Configuration** - Auto-discovers Vizzly TDD server
-- **Native XCTest Integration** - Simple extensions for `XCUIApplication` and `XCUIElement` via the `VizzlyXCTest` helper product
-- **iOS & macOS Support** - Works on both platforms
-- **Automatic Metadata** - Captures device, screen size, and platform info
-- **TDD Mode** - Local visual testing with instant feedback
-- **Cloud Mode** - Team collaboration via Vizzly dashboard
-- **Graceful Degradation** - Tests pass even if Vizzly is unavailable
+## SwiftUI previews
-## Installation
+Install the CLI and preview plugin in your iOS project:
-### Swift Package Manager
-
-Add Vizzly to your test target using Xcode:
-
-1. File → Add Package Dependencies
-2. Enter repository URL: `https://github.com/vizzly-testing/cli`
-3. Select version and add the `VizzlyXCTest` product to your UI test target
-
-The core `Vizzly` product has no XCTest dependency and can also be used from
-native app or test-support code when you want to send PNG data directly.
-
-Or add to your `Package.swift`:
-
-```swift
-dependencies: [
- .package(url: "https://github.com/vizzly-testing/cli", branch: "main")
-],
-targets: [
- .testTarget(
- name: "MyAppUITests",
- dependencies: [
- .product(name: "VizzlyXCTest", package: "cli")
- ]
- )
-]
+```bash
+pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta
```
-Vizzly does not currently ship a CocoaPods podspec. Use Swift Package Manager
-for native app integration.
-
-## Quick Start
+Add this repository as a Swift Package dependency, then add the dynamic
+`VizzlyPreviewRuntime` product to the app target with **Embed & Sign**:
-### 1. Start Vizzly TDD Server
-
-```bash
-cd /path/to/your/ios/project
-vizzly tdd start --open
+```text
+https://github.com/vizzly-testing/cli
```
-This starts a local server that receives screenshots and performs visual
-comparisons. Vizzly uses `http://localhost:47392` by default; if that port is
-busy, use the URL printed by the command.
+For the beta, choose **Exact Version** and enter `0.1.1-beta.0`.
-### 2. Add Vizzly to Your UI Tests
+Install the runtime once from the app initializer:
```swift
-import XCTest
-import Vizzly
-import VizzlyXCTest
+import VizzlyPreviewRuntime
-class MyUITests: XCTestCase {
- let app = XCUIApplication()
-
- func testHomeScreen() {
- app.launch()
+@main
+struct MyApp: App {
+ init() {
+ VizzlyPreviewRuntime.install()
+ }
- // Capture screenshot - that's it!
- app.vizzlyScreenshot(name: "home-screen")
+ var body: some Scene {
+ WindowGroup { ContentView() }
}
}
```
-### 3. Run Your Tests
+Boot an iOS Simulator, then run:
```bash
-# Via Xcode: Cmd+U
-# Or via command line:
-xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'
-```
-
-### 4. View Results
-
-Open the dashboard URL printed by `vizzly tdd start` to see visual comparisons,
-accept/reject changes, and review differences.
-
-For a one-off local run, wrap your `xcodebuild` command:
-
-```bash
-vizzly tdd run \
- "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'" \
- --no-open
-```
-
-That writes local review data under `.vizzly/`. If screenshots were captured,
-Vizzly also creates `.vizzly/report/index.html`; omit `--no-open` when you want
-the report opened automatically.
-
-## Usage Examples
-
-### Basic Screenshot
-
-```swift
-func testLoginScreen() {
- app.launch()
- app.buttons["Login"].tap()
-
- // Capture full screen
- app.vizzlyScreenshot(name: "login-screen")
-}
-```
-
-### Screenshot with Properties
-
-```swift
-func testDarkMode() {
- app.launch()
- enableDarkMode()
-
- app.vizzlyScreenshot(
- name: "home-dark",
- properties: [
- "theme": "dark",
- "feature": "dark-mode"
- ]
- )
-}
-```
-
-### Element Screenshot
-
-```swift
-func testNavigationBar() {
- let navbar = app.navigationBars.firstMatch
-
- // Capture just the navbar
- navbar.vizzlyScreenshot(
- name: "navbar",
- properties: ["component": "navbar"]
- )
-}
+pnpm exec vizzly previews
```
-### Custom Threshold
+Vizzly builds the app, finds its existing `#Preview` declarations, renders each
+one in the Simulator, and writes PNGs to `.vizzly/previews`. Your previews stay
+as stock Apple `#Preview` declarations; there is no Vizzly preview API to keep
+in sync.
-```swift
-func testAnimatedContent() {
- // Allow a higher Delta E comparison threshold for animated content
- app.vizzlyScreenshot(
- name: "animated-banner",
- threshold: 5
- )
-}
-```
+See [PREVIEWS.md](PREVIEWS.md) for package-version details, requirements,
+configuration, CI, and troubleshooting.
-If `threshold` or `minClusterSize` is omitted, the server's configured
-comparison settings are used.
+## XCTest screenshots
-### Multiple Device Orientations
+Add this repository as a Swift Package dependency:
-```swift
-func testResponsiveLayout() {
- app.launch()
-
- // Portrait
- XCUIDevice.shared.orientation = .portrait
- app.vizzlyScreenshot(
- name: "home-portrait",
- properties: ["orientation": "portrait"]
- )
-
- // Landscape
- XCUIDevice.shared.orientation = .landscapeLeft
- app.vizzlyScreenshot(
- name: "home-landscape",
- properties: ["orientation": "landscape"]
- )
-}
+```text
+https://github.com/vizzly-testing/cli
```
-### Using the Client Directly
+Add the `VizzlyXCTest` product to your UI test target. Then capture the app or a
+single element from a test:
```swift
+import XCTest
import Vizzly
+import VizzlyXCTest
-func testWithDirectClient() {
- let screenshot = app.screenshot()
-
- VizzlyClient.shared.screenshot(
- name: "custom-screenshot",
- image: screenshot.pngRepresentation,
- properties: [
- "customProperty": "value",
- "browser": "Safari"
- ],
- threshold: 0
- )
-}
-```
-
-## API Reference
-
-### XCUIApplication Extensions
-
-```swift
-extension XCUIApplication {
- func vizzlyScreenshot(
- name: String,
- properties: [String: Any]? = nil,
- threshold: Double? = nil,
- minClusterSize: Int? = nil,
- fullPage: Bool? = nil,
- buildId: String? = nil,
- requestTimeout: Double? = nil
- ) -> [String: Any]?
-}
-```
-
-### XCUIElement Extensions
-
-```swift
-extension XCUIElement {
- func vizzlyScreenshot(
- name: String,
- properties: [String: Any]? = nil,
- threshold: Double? = nil,
- minClusterSize: Int? = nil,
- buildId: String? = nil,
- requestTimeout: Double? = nil
- ) -> [String: Any]?
-}
-```
-
-### XCTestCase Extensions
-
-```swift
-extension XCTestCase {
- func vizzlyScreenshot(
- name: String,
- app: XCUIApplication,
- properties: [String: Any]? = nil,
- threshold: Double? = nil,
- minClusterSize: Int? = nil,
- fullPage: Bool? = nil,
- buildId: String? = nil,
- requestTimeout: Double? = nil
- ) -> [String: Any]?
-
- func vizzlyScreenshot(
- name: String,
- element: XCUIElement,
- properties: [String: Any]? = nil,
- threshold: Double? = nil,
- minClusterSize: Int? = nil,
- buildId: String? = nil,
- requestTimeout: Double? = nil
- ) -> [String: Any]?
-}
-```
-
-### VizzlyClient
-
-```swift
-class VizzlyClient {
- static let shared: VizzlyClient
-
- init(
- serverUrl: String? = nil,
- autoDiscover: Bool = true,
- failOnDiff: Bool? = nil
- )
-
- func screenshot(
- name: String,
- image: Data,
- properties: [String: Any]? = nil,
- threshold: Double? = nil,
- minClusterSize: Int? = nil,
- fullPage: Bool? = nil,
- buildId: String? = nil,
- requestTimeout: Double? = nil
- ) -> [String: Any]?
-
- var isReady: Bool { get }
- var info: [String: Any] { get }
- func flush()
- func disable(reason: String)
-}
-```
-
-## Configuration
-
-### Auto-Discovery
-
-The SDK automatically discovers a running Vizzly TDD server using this priority order:
-
-1. **VIZZLY_SERVER_URL environment variable** - Explicitly set server URL
-2. **Project server file** - `.vizzly/server.json` in the current directory
-3. **Default port health check** - Tests `http://localhost:47392/health`
-
-When you run `vizzly tdd start`, the CLI writes server info to
-`.vizzly/server.json` in your project. Run UI tests from the project checkout,
-or set `VIZZLY_SERVER_URL` explicitly when your test process starts elsewhere.
-
-### Environment Variables
-
-- `VIZZLY_SERVER_URL` - Server URL (e.g., `http://localhost:47392`)
-- `VIZZLY_BUILD_ID` - Build identifier for grouping screenshots. The SDK also
- auto-discovers `buildId` from `.vizzly/server.json` when present.
-- `VIZZLY_FAIL_ON_DIFF` - Set to `true` or `1` to fail when a local TDD
- comparison returns a visual diff. The SDK also honors `failOnDiff: true` from
- discovered `.vizzly/server.json`.
-
-Swift screenshot calls intentionally expose comparison metadata (`properties`,
-`threshold`, `minClusterSize`, and `fullPage`). They also accept per-call
-`buildId` and `requestTimeout` overrides; `requestTimeout` is measured in
-milliseconds to match the JavaScript and Ruby SDKs.
-
-### Manual Configuration
-
-```swift
-// Override auto-discovery
-let client = VizzlyClient(serverUrl: "http://localhost:47392")
-
-// Fail the SDK call when local TDD mode reports a visual diff
-let strictClient = VizzlyClient(
- serverUrl: "http://localhost:47392",
- failOnDiff: true
-)
-```
-
-## TDD Mode vs Cloud Mode
-
-### TDD Mode (Local Development)
-
-Start the TDD server locally:
-
-```bash
-vizzly tdd start
-```
-
-- Screenshots compared locally using high-performance Rust diffing
-- Instant feedback via dashboard at `http://localhost:47392/dashboard`
-- No API token required
-- Fast iteration cycle
-
-### Cloud Mode (CI/CD)
-
-Set your API token and run in CI:
-
-```bash
-export VIZZLY_TOKEN="your-token-here"
-vizzly run "xcodebuild test -scheme MyApp" --wait
-```
-
-- Screenshots uploaded to Vizzly cloud
-- Team collaboration via web dashboard
-- Supports parallel test execution
-- Returns exit codes for CI integration
-
-## Automatic Metadata
-
-The SDK automatically captures:
-
-- **Platform**: iOS or macOS
-- **Device**: iPhone model, iPad model, or Mac
-- **OS Version**: iOS/macOS version
-- **Viewport**: Screen dimensions and scale factor
-- **Element Type**: When screenshotting elements
-
-This metadata helps differentiate screenshots across devices and configurations.
-
-## Best Practices
-
-### Naming Screenshots
-
-Use descriptive, hierarchical names with dashes:
-
-```swift
-// ✅ Good - Use dashes for hierarchy
-app.vizzlyScreenshot(name: "checkout-payment-form-valid-card")
-app.vizzlyScreenshot(name: "settings-profile-edit-mode")
-
-// ❌ Avoid - Generic names or slashes
-app.vizzlyScreenshot(name: "screenshot1")
-app.vizzlyScreenshot(name: "test")
-app.vizzlyScreenshot(name: "checkout/payment/form") // slashes cause validation errors
-```
-
-### Use Properties for Context
-
-```swift
-app.vizzlyScreenshot(
- name: "product-list",
- properties: [
- "theme": "dark",
- "user": "premium",
- "itemCount": 50
- ]
-)
-```
-
-### Wait for Content
-
-```swift
-func testDynamicContent() {
- let element = app.buttons["Submit"]
-
- // Wait for element to exist
- XCTAssertTrue(element.waitForExistence(timeout: 5))
-
- // Now screenshot
- app.vizzlyScreenshot(name: "submit-button-visible")
-}
-```
-
-### Isolate Visual Tests
-
-Keep visual regression tests separate from functional tests for clarity:
-
-```swift
-// Good structure:
-// - MyAppFunctionalTests.swift (no screenshots)
-// - MyAppVisualTests.swift (Vizzly screenshots)
-```
-
-## CI/CD Integration
-
-### GitHub Actions
-
-```yaml
-name: Visual Tests
-
-on: [push, pull_request]
-
-jobs:
- ios-tests:
- runs-on: macos-latest
- steps:
- - uses: actions/checkout@v3
-
- - name: Run UI tests with Vizzly
- env:
- VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
- run: |
- pnpm exec vizzly run "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15' -resultBundlePath TestResults"
-```
-
-### Fastlane
-
-```ruby
-lane :visual_tests do
- sh "pnpm exec vizzly run \"bundle exec fastlane scan scheme:MyApp devices:'iPhone 15'\""
-end
-```
-
-## Troubleshooting
-
-### Screenshots Not Being Captured
-
-Check if Vizzly is ready:
+final class HomeScreenTests: XCTestCase {
+ func testHomeScreen() {
+ let app = XCUIApplication()
+ app.launch()
-```swift
-override func setUpWithError() throws {
- if VizzlyClient.shared.isReady {
- print("✓ Vizzly ready: \(VizzlyClient.shared.info)")
- } else {
- print("⚠️ Vizzly not available")
+ XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 5))
+ app.vizzlyScreenshot(name: "home")
}
}
```
-### Server Not Found
-
-1. Ensure TDD server is running: `vizzly tdd start`
-2. Check `.vizzly/server.json` exists in your project checkout
-3. Verify the printed server URL is reachable, for example:
- `curl http://localhost:47392/health`
-4. Or explicitly set the printed URL:
- `export VIZZLY_SERVER_URL=http://localhost:47392`
-
-### Visual Differences Not Showing
-
-1. Open dashboard: `http://localhost:47392/dashboard`
-2. Check console output for error messages
-3. Verify screenshot names are consistent across runs
-4. Look for threshold settings that might be too high
-
-## Examples
-
-Check out the `Example/` directory for:
-
-- Basic screenshot tests
-- Component-level screenshots
-- Dark mode testing
-- Orientation changes
-- Custom properties and thresholds
-- Direct client usage
-
-## SDK E2E Tests
-
-The Swift SDK has an end-to-end test path that runs against a real local
-Vizzly TDD server and uploads real PNG bytes through `VizzlyClient`:
+Start a local review session before running the test:
```bash
-pnpm run test:swift:e2e
+pnpm exec vizzly tdd start --open
```
-This command builds the CLI, starts an isolated TDD run in a temp directory,
-and executes the `VizzlyE2ETests` SwiftPM suite.
-
-## Contributing
+See [QUICKSTART.md](QUICKSTART.md) for the shortest setup path and
+[INTEGRATION.md](INTEGRATION.md) for options and CI.
-Bug reports and pull requests are welcome at https://github.com/vizzly-testing/cli
+## Support
-## License
+| Capability | XCTest SDK | Preview capture |
+| --- | --- | --- |
+| iOS | iOS 13+ | iOS 17+ Simulator |
+| macOS | macOS 10.15+ | Not supported |
+| Local TDD | Yes | Yes |
+| Cloud builds | Yes | Yes |
+| Exact Xcode requirement | No | Xcode 26.6 |
+| Fixed layout and orientation traits | Not applicable | Yes |
+| Other SwiftUI preview traits | Not applicable | Reported as capture failures |
+| App integration | UI test target | One app initializer call |
-This SDK is available as open source under the terms of the MIT License.
+Preview capture intentionally has a narrow compatibility range because it uses
+the preview ABI shipped with Xcode. The command checks the Xcode version and
+stops instead of producing screenshots with unknown behavior.
-## Learn More
+## More
-- **Website**: https://vizzly.dev
-- **Documentation**: https://docs.vizzly.dev
-- **GitHub**: https://github.com/vizzly-testing/cli
-- **Support**: support@vizzly.dev
+- [XCTest quick start](QUICKSTART.md)
+- [XCTest integration guide](INTEGRATION.md)
+- [SwiftUI preview guide](PREVIEWS.md)
+- [Example UI test](Example/ExampleUITests.swift)
+- [Changelog](CHANGELOG.md)
diff --git a/clients/swift/Sources/CVizzlyPreviewRuntime/PreviewInterposer.c b/clients/swift/Sources/CVizzlyPreviewRuntime/PreviewInterposer.c
new file mode 100644
index 00000000..dcdbd675
--- /dev/null
+++ b/clients/swift/Sources/CVizzlyPreviewRuntime/PreviewInterposer.c
@@ -0,0 +1,23 @@
+#if defined(__APPLE__)
+#include
+#endif
+
+#if defined(TARGET_OS_IOS) && TARGET_OS_IOS && TARGET_OS_SIMULATOR
+extern void vizzly_preview_replacement(void)
+ __asm("_VizzlyPreviewInitializerReplacement");
+extern void swiftui_preview_initializer(void)
+ __asm("_$s21DeveloperToolsSupport7PreviewV7SwiftUIE_6traits4bodyACSSSg_AA0D5TraitVyAC10ViewTraitsOGdAD0J0_pyScMYcctcfC");
+
+__attribute__((used))
+static struct {
+ const void *replacement;
+ const void *replacee;
+} interposers[] __attribute__((section("__DATA,__interpose"))) = {
+ { (const void *)&vizzly_preview_replacement,
+ (const void *)&swiftui_preview_initializer }
+};
+
+void *VizzlyOriginalPreviewInitializer(void) {
+ return (void *)interposers[0].replacee;
+}
+#endif
diff --git a/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h b/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h
new file mode 100644
index 00000000..4e740ca2
--- /dev/null
+++ b/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h
@@ -0,0 +1,4 @@
+#ifndef CVIZZLY_PREVIEW_RUNTIME_H
+#define CVIZZLY_PREVIEW_RUNTIME_H
+
+#endif
diff --git a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift
new file mode 100644
index 00000000..ebd6278b
--- /dev/null
+++ b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift
@@ -0,0 +1,436 @@
+#if os(iOS) && targetEnvironment(simulator)
+import Darwin
+import DeveloperToolsSupport
+import Foundation
+import SwiftUI
+import UIKit
+
+public enum VizzlyPreviewRuntime {
+ /// True only while `vizzly previews` is rendering this app in Simulator.
+ public static var isCapturing: Bool {
+ ProcessInfo.processInfo.environment["VIZZLY_REGISTRY_TYPE"] != nil
+ }
+
+ /// Enables Vizzly capture when the app is launched by `vizzly previews`.
+ @MainActor
+ public static func install() {
+ startVizzlyPreviewRuntime()
+ }
+}
+
+@available(iOS 17.0, *)
+public typealias VizzlyPreviewBody = @MainActor () -> any View
+
+@available(iOS 17.0, *)
+public typealias VizzlyPreviewInitializer = @convention(thin) @MainActor (
+ String?,
+ [PreviewTrait],
+ @escaping VizzlyPreviewBody
+) -> Preview
+
+@_silgen_name("VizzlyOriginalPreviewInitializer")
+private func originalPreviewInitializerPointer() -> UnsafeRawPointer
+
+@available(iOS 17.0, *)
+@MainActor
+private var capturedPreviewBody: VizzlyPreviewBody?
+
+@available(iOS 17.0, *)
+@MainActor
+private var capturedPreviewName = "Unnamed Preview"
+
+@available(iOS 17.0, *)
+@MainActor
+private var capturedPreviewTraits: [PreviewTrait] = []
+
+@available(iOS 17.0, *)
+@MainActor
+private var captureTargetView: UIView?
+
+@available(iOS 17.0, *)
+@MainActor
+private var activationObserver: NSObjectProtocol?
+
+@available(iOS 17.0, *)
+@MainActor
+private var didInstallPreview = false
+
+@_silgen_name("VizzlyPreviewInitializerReplacement")
+@available(iOS 17.0, *)
+@MainActor
+public func interceptPreviewInitializer(
+ _ name: String?,
+ traits: [PreviewTrait],
+ body: @escaping VizzlyPreviewBody
+) -> Preview {
+ capturedPreviewBody = body
+ capturedPreviewName = name ?? "Unnamed Preview"
+ capturedPreviewTraits = traits
+
+ let original = unsafeBitCast(
+ originalPreviewInitializerPointer(),
+ to: VizzlyPreviewInitializer.self
+ )
+ return original(name, traits, body)
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func emitEvent(_ event: [String: Any]) {
+ guard
+ JSONSerialization.isValidJSONObject(event),
+ let data = try? JSONSerialization.data(withJSONObject: event),
+ let json = String(data: data, encoding: .utf8)
+ else {
+ return
+ }
+
+ print("VIZZLY_PREVIEW_EVENT \(json)")
+ fflush(stdout)
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func traitNumber(after marker: String, in description: String) -> CGFloat? {
+ guard let markerRange = description.range(of: marker) else {
+ return nil
+ }
+
+ let suffix = description[markerRange.upperBound...]
+ let value = suffix.prefix { character in
+ character.isNumber || character == "." || character == "-"
+ }
+ guard let number = Double(value), number > 0 else {
+ return nil
+ }
+ return CGFloat(number)
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func traitDescriptions(
+ _ trait: PreviewTrait
+) -> [String] {
+ guard
+ let traits = Mirror(reflecting: trait).children.first(where: {
+ $0.label == "traits"
+ })?.value
+ else {
+ return []
+ }
+
+ return Mirror(reflecting: traits).children.map {
+ String(reflecting: $0.value)
+ }
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func previewSize(
+ for traits: [PreviewTrait],
+ screenSize: CGSize
+) throws -> CGSize? {
+ var requestedSize: CGSize?
+ let descriptions = traits.flatMap(traitDescriptions)
+
+ for description in descriptions {
+ if description.contains("PreviewLayout.fixed") {
+ guard
+ let width = traitNumber(
+ after: "PreviewLayout.fixed(width: ",
+ in: description
+ ),
+ let height = traitNumber(after: ", height: ", in: description)
+ else {
+ throw PreviewRuntimeError.unsupportedTraits(descriptions.count)
+ }
+ requestedSize = CGSize(width: width, height: height)
+ continue
+ }
+
+ if description.contains("PreviewInterfaceOrientation.landscape") {
+ requestedSize = requestedSize ?? CGSize(
+ width: max(screenSize.width, screenSize.height),
+ height: min(screenSize.width, screenSize.height)
+ )
+ continue
+ }
+
+ if description.contains("PreviewInterfaceOrientation.portrait")
+ || description.contains("PreviewLayout.device")
+ {
+ continue
+ }
+
+ throw PreviewRuntimeError.unsupportedTraits(descriptions.count)
+ }
+
+ guard traits.isEmpty || !descriptions.isEmpty else {
+ throw PreviewRuntimeError.unsupportedTraits(traits.count)
+ }
+
+ return requestedSize
+}
+
+@available(iOS 17.0, *)
+private struct ResolvedPreview {
+ let size: CGSize?
+ let view: AnyView
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func resolvePreview(screenSize: CGSize) throws -> ResolvedPreview {
+ guard
+ let registryName = ProcessInfo.processInfo.environment[
+ "VIZZLY_REGISTRY_TYPE"
+ ],
+ let loadedType = _typeByName(registryName),
+ let registry = loadedType as? any PreviewRegistry.Type
+ else {
+ throw PreviewRuntimeError.registryUnavailable
+ }
+
+ _ = try registry.makePreview()
+
+ guard let body = capturedPreviewBody else {
+ throw PreviewRuntimeError.bodyUnavailable
+ }
+
+ let size = try previewSize(
+ for: capturedPreviewTraits,
+ screenSize: screenSize
+ )
+ let view = body()
+ emitEvent([
+ "protocolVersion": 1,
+ "type": "preview-resolved",
+ "name": capturedPreviewName,
+ "registryType": registryName,
+ "traitCount": capturedPreviewTraits.count,
+ "viewType": String(reflecting: type(of: view)),
+ ])
+ return ResolvedPreview(size: size, view: AnyView(view))
+}
+
+@available(iOS 17.0, *)
+private struct InjectedPreviewRoot: View {
+ let preview: AnyView
+
+ var body: some View {
+ preview.background {
+ CaptureProbe().frame(width: 0, height: 0)
+ }
+ }
+}
+
+@available(iOS 17.0, *)
+private struct CaptureProbe: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> CaptureController {
+ CaptureController()
+ }
+
+ func updateUIViewController(
+ _ uiViewController: CaptureController,
+ context: Context
+ ) {}
+
+ final class CaptureController: UIViewController {
+ private var didCapture = false
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ guard !didCapture else { return }
+ didCapture = true
+
+ Task { @MainActor in
+ do {
+ let filename = try captureWindow()
+ emitEvent([
+ "protocolVersion": 1,
+ "type": "capture-complete",
+ "filename": filename,
+ ])
+ exit(EXIT_SUCCESS)
+ } catch {
+ emitFailure(error)
+ exit(EXIT_FAILURE)
+ }
+ }
+ }
+
+ @MainActor
+ private func captureWindow() throws -> String {
+ flushPendingRenderTransactions()
+
+ guard let targetView = captureTargetView ?? view.window else {
+ throw PreviewRuntimeError.windowUnavailable
+ }
+
+ targetView.layoutIfNeeded()
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = targetView.window?.screen.scale ?? UIScreen.main.scale
+ format.opaque = true
+ let renderer = UIGraphicsImageRenderer(
+ bounds: targetView.bounds,
+ format: format
+ )
+ let image = renderer.image { _ in
+ targetView.drawHierarchy(
+ in: targetView.bounds,
+ afterScreenUpdates: true
+ )
+ }
+
+ guard let png = image.pngData() else {
+ throw PreviewRuntimeError.pngEncodingFailed
+ }
+
+ let filename = ProcessInfo.processInfo.environment[
+ "VIZZLY_OUTPUT_FILENAME"
+ ] ?? "vizzly-preview.png"
+ let documentsURL = try FileManager.default.url(
+ for: .documentDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ )
+ try png.write(
+ to: documentsURL.appendingPathComponent(filename),
+ options: .atomic
+ )
+ return filename
+ }
+
+ @MainActor
+ private func flushPendingRenderTransactions() {
+ CATransaction.flush()
+ }
+ }
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func installPreview(in scene: UIWindowScene) {
+ guard !didInstallPreview else { return }
+ didInstallPreview = true
+
+ if let observer = activationObserver {
+ NotificationCenter.default.removeObserver(observer)
+ activationObserver = nil
+ }
+
+ do {
+ guard let window = scene.windows.first(where: \.isKeyWindow)
+ ?? scene.windows.first(where: { !$0.isHidden && $0.alpha > 0 })
+ ?? scene.windows.first else {
+ throw PreviewRuntimeError.windowUnavailable
+ }
+
+ let preview = try resolvePreview(screenSize: window.bounds.size)
+ let hostingController = UIHostingController(
+ rootView: InjectedPreviewRoot(preview: preview.view)
+ )
+ captureTargetView = nil
+
+ if let size = preview.size {
+ let container = UIViewController()
+ container.addChild(hostingController)
+ container.view.addSubview(hostingController.view)
+ hostingController.view.frame = CGRect(origin: .zero, size: size)
+ hostingController.didMove(toParent: container)
+ captureTargetView = hostingController.view
+ window.rootViewController = container
+ } else {
+ window.rootViewController = hostingController
+ }
+ window.makeKeyAndVisible()
+ } catch {
+ emitFailure(error)
+ exit(EXIT_FAILURE)
+ }
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func emitFailure(_ error: Error) {
+ var event: [String: Any] = [
+ "protocolVersion": 1,
+ "type": "capture-failed",
+ "message": error.localizedDescription,
+ ]
+ if capturedPreviewBody != nil {
+ event["name"] = capturedPreviewName
+ }
+ emitEvent(event)
+}
+
+@available(iOS 17.0, *)
+@MainActor
+private func startPreviewObservation() {
+ guard activationObserver == nil, !didInstallPreview else { return }
+ activationObserver = NotificationCenter.default.addObserver(
+ forName: UIScene.didActivateNotification,
+ object: nil,
+ queue: .main
+ ) { notification in
+ MainActor.assumeIsolated {
+ guard let scene = notification.object as? UIWindowScene else {
+ return
+ }
+ installPreview(in: scene)
+ }
+ }
+}
+
+@_cdecl("VizzlyPreviewRuntimeStart")
+public func startVizzlyPreviewRuntime() {
+ guard
+ ProcessInfo.processInfo.environment["VIZZLY_REGISTRY_TYPE"] != nil,
+ #available(iOS 17.0, *)
+ else {
+ return
+ }
+
+ MainActor.assumeIsolated {
+ startPreviewObservation()
+ }
+}
+
+private enum PreviewRuntimeError: LocalizedError {
+ case bodyUnavailable
+ case pngEncodingFailed
+ case registryUnavailable
+ case unsupportedTraits(Int)
+ case windowUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .bodyUnavailable:
+ return "The #Preview body was not intercepted"
+ case .pngEncodingFailed:
+ return "The rendered preview could not be encoded as PNG"
+ case .registryUnavailable:
+ return "The generated #Preview registry could not be loaded"
+ case .unsupportedTraits(let count):
+ return "This preview uses \(count) trait(s), which are not supported yet"
+ case .windowUnavailable:
+ return "The app did not create a window for preview capture"
+ }
+ }
+}
+#else
+public enum VizzlyPreviewRuntime {
+ /// Always false outside the iOS Simulator capture runtime.
+ public static var isCapturing: Bool { false }
+
+ /// Has no effect outside an iOS Simulator capture launch.
+ @MainActor
+ public static func install() {
+ startVizzlyPreviewRuntime()
+ }
+}
+
+@_cdecl("VizzlyPreviewRuntimeStart")
+public func startVizzlyPreviewRuntime() {}
+#endif
diff --git a/clients/swift/package.json b/clients/swift/package.json
new file mode 100644
index 00000000..236f48b2
--- /dev/null
+++ b/clients/swift/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "@vizzly-testing/swift",
+ "version": "0.1.0",
+ "description": "Native Swift and SwiftUI preview integration for Vizzly",
+ "keywords": [
+ "vizzly",
+ "swift",
+ "swiftui",
+ "xcode",
+ "visual-testing",
+ "screenshot-testing",
+ "plugin"
+ ],
+ "homepage": "https://vizzly.dev",
+ "bugs": "https://github.com/vizzly-testing/cli/issues",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/vizzly-testing/cli.git",
+ "directory": "clients/swift"
+ },
+ "license": "MIT",
+ "author": "Stubborn Mule Software ",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.js",
+ "./plugin": "./src/plugin.js"
+ },
+ "vizzlyPlugin": "./src/plugin.js",
+ "files": [
+ "src",
+ "PREVIEWS.md",
+ "README.md",
+ "CHANGELOG.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "check": "pnpm run lint && pnpm test",
+ "test": "node --test --test-reporter=spec tests-js/*.test.js",
+ "test:previews:e2e": "node scripts/run-preview-e2e.js",
+ "lint": "biome check src tests-js scripts package.json",
+ "format": "biome format --write src tests-js scripts package.json",
+ "prepublishOnly": "pnpm run check"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "@vizzly-testing/cli": ">=0.36.0"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.5.10",
+ "@vizzly-testing/cli": "workspace:*"
+ }
+}
diff --git a/clients/swift/scripts/run-preview-e2e.js b/clients/swift/scripts/run-preview-e2e.js
new file mode 100644
index 00000000..a2b38c91
--- /dev/null
+++ b/clients/swift/scripts/run-preview-e2e.js
@@ -0,0 +1,91 @@
+import assert from 'node:assert/strict';
+import { mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { runPreviewCapture } from '../src/preview-runner.js';
+
+let device = process.env.VIZZLY_SIMULATOR_UDID;
+let outputPath = await mkdtemp(join(tmpdir(), 'vizzly-preview-e2e-'));
+
+try {
+ let capture = () =>
+ runPreviewCapture({
+ container: resolve(
+ import.meta.dirname,
+ '..',
+ 'Fixtures',
+ 'PreviewFixture',
+ 'PreviewFixture.xcodeproj'
+ ),
+ device,
+ configuration: 'Debug',
+ outputPath,
+ onProgress: message => process.stdout.write(`${message}\n`),
+ });
+ let manifest = await capture();
+
+ assert.deepEqual(manifest.previews.map(preview => preview.name).sort(), [
+ 'Card / Dark',
+ 'Fixed Layout',
+ 'Stateful Counter',
+ ]);
+ assert.ok(
+ manifest.previews.every(
+ preview =>
+ preview.width > 0 &&
+ preview.height > 0 &&
+ /^[a-f0-9]{64}$/.test(preview.sha256)
+ )
+ );
+ assert.notEqual(manifest.previews[0].sha256, manifest.previews[1].sha256);
+ let fixedLayout = manifest.previews.find(
+ preview => preview.name === 'Fixed Layout'
+ );
+ assert.equal(fixedLayout.width, 960);
+ assert.equal(fixedLayout.height, 600);
+ assert.deepEqual(
+ manifest.failures.map(failure => failure.name),
+ ['Unsupported Size That Fits']
+ );
+ assert.match(manifest.failures[0].message, /trait.*not supported/i);
+
+ let repeatedManifest = await capture();
+ assert.deepEqual(
+ repeatedManifest.previews.map(({ name, width, height }) => ({
+ name,
+ width,
+ height,
+ })),
+ manifest.previews.map(({ name, width, height }) => ({
+ name,
+ width,
+ height,
+ }))
+ );
+ assert.ok(
+ repeatedManifest.previews.every(preview =>
+ /^[a-f0-9]{64}$/.test(preview.sha256)
+ )
+ );
+ assert.deepEqual(
+ repeatedManifest.failures.map(({ name, message }) => ({ name, message })),
+ manifest.failures.map(({ name, message }) => ({ name, message }))
+ );
+
+ let missingPreviewPath = join(outputPath, repeatedManifest.previews[0].file);
+ await writeFile(missingPreviewPath, 'changed outside Vizzly');
+ await assert.rejects(capture, /output contains files not created by Vizzly/);
+ await unlink(missingPreviewPath);
+ await assert.rejects(capture, /output contains files not created by Vizzly/);
+ assert.equal(
+ JSON.parse(await readFile(join(outputPath, 'manifest.json'), 'utf8'))
+ .previews.length,
+ 3
+ );
+
+ process.stdout.write(
+ `Verified ${manifest.previews.length} stock #Preview screenshots, fixed-layout traits, isolated failures, and safe output replacement through the linked runtime\n`
+ );
+} finally {
+ await rm(outputPath, { recursive: true, force: true });
+}
diff --git a/clients/swift/src/index.js b/clients/swift/src/index.js
new file mode 100644
index 00000000..6d096c6e
--- /dev/null
+++ b/clients/swift/src/index.js
@@ -0,0 +1,215 @@
+import { writeFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { runPreviewCapture } from './preview-runner.js';
+import {
+ buildCloudRunOptions,
+ findLocalTddServer,
+ hasApiToken,
+ uploadCapturedPreviews,
+} from './upload.js';
+
+export function resolvePreviewOptions(options, config) {
+ return {
+ captureTimeout: options.captureTimeout ?? config.captureTimeout ?? 30_000,
+ configuration: options.configuration ?? config.configuration ?? 'Debug',
+ device: options.device ?? config.device,
+ outputPath: options.output ?? config.output ?? '.vizzly/previews',
+ scheme: options.scheme ?? config.scheme,
+ upload: options.upload ?? config.upload ?? true,
+ };
+}
+
+export function assertCompleteCapture(manifest) {
+ let failures = manifest.failures ?? [];
+ if (failures.length === 0) {
+ return;
+ }
+
+ throw new Error(
+ `${failures.length} of ${manifest.previews.length + failures.length} ` +
+ `SwiftUI previews failed. See ${join(manifest.outputPath, 'manifest.json')}`
+ );
+}
+
+async function saveManifest(manifest) {
+ await writeFile(
+ join(manifest.outputPath, 'manifest.json'),
+ `${JSON.stringify(manifest, null, 2)}\n`
+ );
+}
+
+function requireCloudServices(services) {
+ let requiredMethods = [
+ services?.git?.detect,
+ services?.testRunner?.once,
+ services?.testRunner?.createBuild,
+ services?.testRunner?.finalizeBuild,
+ services?.serverManager?.start,
+ services?.serverManager?.stop,
+ ];
+
+ if (requiredMethods.some(method => typeof method !== 'function')) {
+ throw new Error(
+ 'Cloud preview uploads require a current @vizzly-testing/cli installation'
+ );
+ }
+}
+
+export async function run(container, options = {}, context = {}) {
+ let previewOptions = resolvePreviewOptions(
+ options,
+ context.config?.swiftPreviews ?? {}
+ );
+
+ let output = context.output ?? {
+ info: message => process.stderr.write(`${message}\n`),
+ warn: message => process.stderr.write(`${message}\n`),
+ };
+ let services = context.services;
+ let screenshotClient = context.screenshotClient;
+ let vizzlyConfig = context.config ?? {};
+ let serverManager = null;
+ let testRunner = null;
+ let buildId = null;
+ let buildUrl = null;
+ let finalizationAttempted = false;
+ let startTime = Date.now();
+
+ async function resolveScreenshotClient() {
+ screenshotClient ??= await import('@vizzly-testing/cli/client');
+ return screenshotClient;
+ }
+
+ try {
+ output.info(
+ 'Preparing to build the iOS app and discover stock #Preview declarations'
+ );
+ let manifest = await runPreviewCapture({
+ container,
+ ...previewOptions,
+ onProgress: message => output.info(message),
+ onFailure: message => output.warn(message),
+ });
+ let upload;
+
+ if (!previewOptions.upload) {
+ upload = { mode: 'disabled', uploaded: 0 };
+ output.info('Kept preview screenshots local because upload is disabled');
+ } else {
+ let tddServerUrl = await findLocalTddServer([
+ process.cwd(),
+ dirname(manifest.container),
+ ]);
+
+ if (tddServerUrl) {
+ output.info('Using the active local Vizzly TDD server');
+ let result = await uploadCapturedPreviews({
+ comparison: vizzlyConfig.comparison,
+ manifest,
+ screenshotClient: await resolveScreenshotClient(),
+ serverUrl: tddServerUrl,
+ });
+ upload = {
+ mode: 'tdd',
+ serverUrl: tddServerUrl,
+ uploaded: result.uploaded,
+ };
+ } else if (hasApiToken(vizzlyConfig)) {
+ requireCloudServices(services);
+ output.info('Creating a Vizzly cloud build');
+ testRunner = services.testRunner;
+ serverManager = services.serverManager;
+ testRunner.once('build-created', build => {
+ buildUrl = build.url ?? null;
+ });
+ let gitInfo = await services.git.detect({
+ buildPrefix: 'SwiftUI Previews',
+ });
+ let runOptions = buildCloudRunOptions(vizzlyConfig, gitInfo);
+ buildId = await testRunner.createBuild(runOptions, false);
+ if (!buildId) {
+ throw new Error('Vizzly did not create a cloud build');
+ }
+ await serverManager.start(buildId, false, false);
+ let result = await uploadCapturedPreviews({
+ buildId,
+ comparison: vizzlyConfig.comparison,
+ manifest,
+ screenshotClient: await resolveScreenshotClient(),
+ serverUrl: `http://localhost:${runOptions.port}`,
+ });
+ finalizationAttempted = true;
+ await testRunner.finalizeBuild(
+ buildId,
+ false,
+ manifest.failures.length === 0,
+ Date.now() - startTime
+ );
+ upload = {
+ buildId,
+ buildUrl,
+ mode: 'cloud',
+ uploaded: result.uploaded,
+ };
+ } else {
+ upload = {
+ mode: 'local-only',
+ reason: 'No active TDD server or VIZZLY_TOKEN was found',
+ uploaded: 0,
+ };
+ output.warn(
+ 'No active TDD server or API token found; kept preview screenshots local'
+ );
+ output.info('Run `vizzly tdd start` or set VIZZLY_TOKEN to upload');
+ }
+ }
+
+ manifest = { ...manifest, upload };
+ await saveManifest(manifest);
+
+ if (options.json) {
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
+ } else {
+ output.info(
+ `Captured ${manifest.previews.length} SwiftUI previews in ${manifest.outputPath}`
+ );
+ if (upload.mode === 'tdd') {
+ output.info(`Sent ${upload.uploaded} previews to local Vizzly TDD`);
+ }
+ if (upload.mode === 'cloud') {
+ output.info(`Uploaded ${upload.uploaded} previews to Vizzly`);
+ if (upload.buildUrl) {
+ output.info(`View results: ${upload.buildUrl}`);
+ }
+ }
+ }
+
+ assertCompleteCapture(manifest);
+ return manifest;
+ } catch (error) {
+ if (testRunner && buildId && !finalizationAttempted) {
+ finalizationAttempted = true;
+ try {
+ await testRunner.finalizeBuild(
+ buildId,
+ false,
+ false,
+ Date.now() - startTime
+ );
+ } catch {
+ // Preserve the capture or upload error that caused the failed build.
+ }
+ }
+ throw error;
+ } finally {
+ if (serverManager) {
+ try {
+ await serverManager.stop();
+ } catch {
+ // The build result is more useful than a cleanup-only failure.
+ }
+ }
+ }
+}
+
+export { run as default, runPreviewCapture };
diff --git a/clients/swift/src/plugin.js b/clients/swift/src/plugin.js
new file mode 100644
index 00000000..ad1ad435
--- /dev/null
+++ b/clients/swift/src/plugin.js
@@ -0,0 +1,57 @@
+import packageJson from '../package.json' with { type: 'json' };
+import { run } from './index.js';
+
+function parsePositiveInteger(value) {
+ let parsed = Number(value);
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ throw new Error('Expected a positive integer');
+ }
+ return parsed;
+}
+
+export default {
+ name: 'swift-previews',
+ version: packageJson.version,
+ configSchema: {
+ swiftPreviews: {
+ captureTimeout: 30_000,
+ configuration: 'Debug',
+ device: null,
+ output: '.vizzly/previews',
+ scheme: null,
+ upload: true,
+ },
+ },
+
+ register(program, context) {
+ program
+ .command('previews [container]')
+ .description(
+ 'Render screenshots from stock SwiftUI #Preview declarations'
+ )
+ .option(
+ '--scheme ',
+ 'Xcode scheme (auto-detected when exactly one is available)'
+ )
+ .option(
+ '--device ',
+ 'Simulator UDID (auto-detected when exactly one iOS Simulator is booted)'
+ )
+ .option('--configuration ', 'Build configuration')
+ .option(
+ '--capture-timeout ',
+ 'Maximum time to render each preview',
+ parsePositiveInteger
+ )
+ .option('--output ', 'Screenshot output directory')
+ .option(
+ '--no-upload',
+ 'Capture local PNGs without sending them to Vizzly'
+ )
+ .option('--json', 'Print the capture manifest as JSON')
+ .action(async (container = '.', options) => {
+ let mergedOptions = { ...program.opts(), ...options };
+ await run(container, mergedOptions, context);
+ });
+ },
+};
diff --git a/clients/swift/src/preview-runner.js b/clients/swift/src/preview-runner.js
new file mode 100644
index 00000000..a6495409
--- /dev/null
+++ b/clients/swift/src/preview-runner.js
@@ -0,0 +1,819 @@
+import { spawn } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import {
+ access,
+ copyFile,
+ mkdir,
+ mkdtemp,
+ readdir,
+ readFile,
+ rename,
+ rm,
+ writeFile,
+} from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { basename, dirname, extname, join, resolve } from 'node:path';
+
+let eventPrefix = 'VIZZLY_PREVIEW_EVENT ';
+let supportedXcodeVersion = '26.6';
+let previewRuntimeInstallName =
+ '@rpath/VizzlyPreviewRuntime.framework/VizzlyPreviewRuntime';
+
+function invalidPng() {
+ throw new Error('Preview capture did not produce a valid PNG');
+}
+
+export function parseRegistryTypes(output) {
+ let registries = new Set();
+
+ for (let line of output.split('\n')) {
+ let match = line.trim().match(/^_\$s(.+fMu_V)Mn$/);
+ if (match) {
+ registries.add(match[1]);
+ }
+ }
+
+ return [...registries].sort();
+}
+
+export function parseRuntimeEvents(output) {
+ let events = [];
+
+ for (let line of output.split('\n')) {
+ if (!line.startsWith(eventPrefix)) {
+ continue;
+ }
+
+ let event = JSON.parse(line.slice(eventPrefix.length));
+ if (event.protocolVersion !== 1 || typeof event.type !== 'string') {
+ throw new Error('The Swift preview runtime emitted an unsupported event');
+ }
+ events.push(event);
+ }
+
+ return events;
+}
+
+export function readPngMetadata(buffer) {
+ let signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+ if (buffer.length < 45 || !buffer.subarray(0, 8).equals(signature)) {
+ invalidPng();
+ }
+
+ let offset = 8;
+ let width;
+ let height;
+ let foundEnd = false;
+ while (offset + 12 <= buffer.length) {
+ let length = buffer.readUInt32BE(offset);
+ let type = buffer.toString('ascii', offset + 4, offset + 8);
+ let nextOffset = offset + 12 + length;
+ if (nextOffset > buffer.length) {
+ invalidPng();
+ }
+
+ if (offset === 8) {
+ if (type !== 'IHDR' || length !== 13) {
+ invalidPng();
+ }
+ width = buffer.readUInt32BE(offset + 8);
+ height = buffer.readUInt32BE(offset + 12);
+ }
+
+ if (type === 'IEND') {
+ foundEnd = length === 0;
+ break;
+ }
+ offset = nextOffset;
+ }
+
+ if (!foundEnd || !width || !height) {
+ invalidPng();
+ }
+
+ return {
+ width,
+ height,
+ sha256: createHash('sha256').update(buffer).digest('hex'),
+ };
+}
+
+export function parseSchemes(output) {
+ let payload = JSON.parse(output);
+ return [...(payload.project?.schemes ?? payload.workspace?.schemes ?? [])]
+ .filter(scheme => typeof scheme === 'string' && scheme.length > 0)
+ .sort();
+}
+
+export function schemeBuildsApplication(output) {
+ let settingsGroups = JSON.parse(output);
+ return settingsGroups.some(item =>
+ item.buildSettings?.FULL_PRODUCT_NAME?.endsWith('.app')
+ );
+}
+
+export function selectScheme(schemes, requestedScheme) {
+ if (requestedScheme) {
+ if (!schemes.includes(requestedScheme)) {
+ throw new Error(
+ `${requestedScheme} is not an available Xcode scheme. ` +
+ `Available schemes: ${schemes.join(', ') || 'none'}`
+ );
+ }
+ return { name: requestedScheme, selection: 'explicit' };
+ }
+
+ if (schemes.length === 0) {
+ throw new Error(
+ 'No shared Xcode schemes are available. Share a scheme in Xcode or ' +
+ 'pass --scheme .'
+ );
+ }
+
+ if (schemes.length > 1) {
+ throw new Error(
+ `More than one Xcode scheme is available: ${schemes.join(', ')}. ` +
+ 'Pass --scheme to choose one.'
+ );
+ }
+
+ return { name: schemes[0], selection: 'automatic' };
+}
+
+function displayRuntime(runtimeIdentifier) {
+ let identifier = runtimeIdentifier.split('.').at(-1);
+ return identifier.replace(/^iOS-/, 'iOS ').replaceAll('-', '.');
+}
+
+export function parseBootedIOSSimulators(output) {
+ let payload = JSON.parse(output);
+ let simulators = [];
+
+ for (let [runtimeIdentifier, devices] of Object.entries(
+ payload.devices ?? {}
+ )) {
+ if (!runtimeIdentifier.includes('.SimRuntime.iOS-')) {
+ continue;
+ }
+
+ for (let device of devices) {
+ if (device.state !== 'Booted' || device.isAvailable !== true) {
+ continue;
+ }
+
+ simulators.push({
+ name: device.name,
+ runtime: displayRuntime(runtimeIdentifier),
+ udid: device.udid,
+ });
+ }
+ }
+
+ return simulators.sort((left, right) =>
+ `${left.name}\0${left.udid}`.localeCompare(`${right.name}\0${right.udid}`)
+ );
+}
+
+function formatSimulator(simulator) {
+ return `${simulator.name} (${simulator.runtime}, ${simulator.udid})`;
+}
+
+export function selectBootedIOSSimulator(simulators, requestedDevice) {
+ if (requestedDevice) {
+ let selected = simulators.find(
+ simulator => simulator.udid === requestedDevice
+ );
+ if (!selected) {
+ throw new Error(
+ `${requestedDevice} is not a booted iOS Simulator. ` +
+ 'Boot it first or omit --device to auto-select.'
+ );
+ }
+ return { ...selected, selection: 'explicit' };
+ }
+
+ if (simulators.length === 0) {
+ throw new Error(
+ 'No booted iOS Simulator was found. ' +
+ 'Open Simulator or boot one from Xcode, then rerun the command.'
+ );
+ }
+
+ if (simulators.length > 1) {
+ let choices = simulators
+ .map(simulator => ` - ${formatSimulator(simulator)}`)
+ .join('\n');
+ throw new Error(
+ `More than one iOS Simulator is booted:\n${choices}\n` +
+ 'Pass --device to choose one.'
+ );
+ }
+
+ return { ...simulators[0], selection: 'automatic' };
+}
+
+function runCommand(executable, args, options = {}) {
+ return new Promise((resolvePromise, rejectPromise) => {
+ let signal = options.timeoutMs
+ ? AbortSignal.timeout(options.timeoutMs)
+ : undefined;
+ let child = spawn(executable, args, {
+ cwd: options.cwd,
+ env: options.env ?? process.env,
+ signal,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = [];
+ let stderr = [];
+
+ child.stdout.on('data', chunk => stdout.push(chunk));
+ child.stderr.on('data', chunk => stderr.push(chunk));
+ child.once('error', error => {
+ if (error.name === 'AbortError') {
+ let timeoutError = new Error(
+ `${basename(executable)} timed out after ${options.timeoutMs}ms`
+ );
+ timeoutError.code = 'ETIMEDOUT';
+ rejectPromise(timeoutError);
+ return;
+ }
+ rejectPromise(error);
+ });
+ child.once('close', (exitCode, terminationSignal) => {
+ let result = {
+ exitCode,
+ signal: terminationSignal,
+ stdout: Buffer.concat(stdout).toString('utf8'),
+ stderr: Buffer.concat(stderr).toString('utf8'),
+ };
+
+ if (exitCode !== 0 && !options.allowFailure) {
+ let detail = result.stderr.trim() || result.stdout.trim();
+ rejectPromise(
+ new Error(
+ `${basename(executable)} failed with exit ${exitCode}${detail ? `: ${detail}` : ''}`
+ )
+ );
+ return;
+ }
+ resolvePromise(result);
+ });
+ });
+}
+
+async function pathExists(path) {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function resolveContainer(input) {
+ let candidate = resolve(input);
+ let extension = extname(candidate);
+ if (extension === '.xcodeproj' || extension === '.xcworkspace') {
+ return candidate;
+ }
+
+ let entries = await readdir(candidate, { withFileTypes: true });
+ let containers = entries
+ .filter(
+ entry =>
+ entry.isDirectory() &&
+ (entry.name.endsWith('.xcworkspace') ||
+ entry.name.endsWith('.xcodeproj'))
+ )
+ .map(entry => join(candidate, entry.name));
+
+ let workspaces = containers.filter(path => path.endsWith('.xcworkspace'));
+ let selected = workspaces.length === 1 ? workspaces : containers;
+ if (selected.length !== 1) {
+ throw new Error(
+ `Expected exactly one Xcode project or workspace in ${candidate}`
+ );
+ }
+ return selected[0];
+}
+
+function containerArguments(container) {
+ return container.endsWith('.xcworkspace')
+ ? ['-workspace', container]
+ : ['-project', container];
+}
+
+async function resolveScheme(container, requestedScheme) {
+ let result = await runCommand('xcodebuild', [
+ ...containerArguments(container),
+ '-list',
+ '-json',
+ ]);
+ let schemes = parseSchemes(result.stdout);
+ if (requestedScheme) {
+ return selectScheme(schemes, requestedScheme);
+ }
+
+ let schemeChecks = await Promise.all(
+ schemes.map(async scheme => {
+ let settings = await runCommand(
+ 'xcodebuild',
+ [
+ ...containerArguments(container),
+ '-scheme',
+ scheme,
+ '-showBuildSettings',
+ '-json',
+ ],
+ { allowFailure: true }
+ );
+ return settings.exitCode === 0 && schemeBuildsApplication(settings.stdout)
+ ? scheme
+ : undefined;
+ })
+ );
+ return selectScheme(schemeChecks.filter(Boolean));
+}
+
+async function assertSupportedToolchain() {
+ let result = await runCommand('xcodebuild', ['-version']);
+ let match = result.stdout.match(/^Xcode (\S+)/m);
+ if (!match || match[1] !== supportedXcodeVersion) {
+ let detectedVersion = match?.[1] ?? 'unknown';
+ throw new Error(
+ `Unsupported preview ABI for Xcode ${detectedVersion}. ` +
+ `This release supports Xcode ${supportedXcodeVersion}`
+ );
+ }
+ return match[1];
+}
+
+async function resolveSimulator(requestedDevice) {
+ let result = await runCommand('xcrun', [
+ 'simctl',
+ 'list',
+ 'devices',
+ 'booted',
+ '--json',
+ ]);
+ let simulators = parseBootedIOSSimulators(result.stdout);
+ return selectBootedIOSSimulator(simulators, requestedDevice);
+}
+
+async function validateOutputPath(outputPath) {
+ if (!(await pathExists(outputPath))) {
+ return;
+ }
+
+ let entries = await readdir(outputPath, { withFileTypes: true });
+ if (entries.length === 0) {
+ return;
+ }
+
+ let manifest;
+ try {
+ manifest = JSON.parse(
+ await readFile(join(outputPath, 'manifest.json'), 'utf8')
+ );
+ } catch {
+ throw unmanagedOutputError(outputPath);
+ }
+
+ if (manifest.protocolVersion !== 1 || !Array.isArray(manifest.previews)) {
+ throw unmanagedOutputError(outputPath);
+ }
+
+ let previewFiles = manifest.previews.map(preview => preview?.file);
+ let uniquePreviewFiles = new Set(previewFiles);
+ if (
+ previewFiles.some(
+ file => !file || file === 'manifest.json' || basename(file) !== file
+ ) ||
+ uniquePreviewFiles.size !== previewFiles.length
+ ) {
+ throw unmanagedOutputError(outputPath);
+ }
+
+ let expectedEntries = new Set(['manifest.json', ...uniquePreviewFiles]);
+ if (
+ entries.length !== expectedEntries.size ||
+ entries.some(entry => !entry.isFile() || !expectedEntries.has(entry.name))
+ ) {
+ throw unmanagedOutputError(outputPath);
+ }
+
+ for (let preview of manifest.previews) {
+ let contents = await readFile(join(outputPath, preview.file));
+ let sha256 = createHash('sha256').update(contents).digest('hex');
+ if (preview.sha256 !== sha256) {
+ throw unmanagedOutputError(outputPath);
+ }
+ }
+}
+
+function unmanagedOutputError(outputPath) {
+ return new Error(
+ `Preview output contains files not created by Vizzly: ${outputPath}`
+ );
+}
+
+function selectionAction(selection) {
+ return selection === 'automatic' ? 'Auto-selected' : 'Using';
+}
+
+async function replaceOutputDirectory(stagingPath, outputPath) {
+ let hadPreviousOutput = await pathExists(outputPath);
+ let backupPath = `${stagingPath}-previous`;
+ if (hadPreviousOutput) {
+ await rename(outputPath, backupPath);
+ }
+
+ try {
+ await rename(stagingPath, outputPath);
+ } catch (error) {
+ if (hadPreviousOutput) {
+ await rename(backupPath, outputPath);
+ }
+ throw error;
+ }
+
+ if (hadPreviousOutput) {
+ await rm(backupPath, { recursive: true, force: true });
+ }
+}
+
+function xcodeArguments({
+ container,
+ scheme,
+ device,
+ configuration,
+ derivedDataPath,
+}) {
+ return [
+ ...containerArguments(container),
+ '-scheme',
+ scheme,
+ '-configuration',
+ configuration,
+ '-sdk',
+ 'iphonesimulator',
+ '-destination',
+ `id=${device}`,
+ '-derivedDataPath',
+ derivedDataPath,
+ 'ARCHS=arm64',
+ 'ONLY_ACTIVE_ARCH=YES',
+ ];
+}
+
+async function buildApplication(options) {
+ let args = xcodeArguments(options);
+ await runCommand('xcodebuild', [...args, 'build']);
+ let settingsResult = await runCommand('xcodebuild', [
+ ...args,
+ '-showBuildSettings',
+ '-json',
+ ]);
+ let settingsGroups = JSON.parse(settingsResult.stdout);
+ let group = settingsGroups.find(item =>
+ item.buildSettings?.FULL_PRODUCT_NAME?.endsWith('.app')
+ );
+ if (!group) {
+ throw new Error(`Scheme ${options.scheme} did not produce an iOS app`);
+ }
+
+ let settings = group.buildSettings;
+ let appPath = join(settings.TARGET_BUILD_DIR, settings.FULL_PRODUCT_NAME);
+ if (!(await pathExists(appPath))) {
+ throw new Error(`Built app was not found at ${appPath}`);
+ }
+
+ return { appPath, settings };
+}
+
+export function applicationBinaryCandidates(appPath, settings) {
+ let candidates = [];
+ if (settings.EXECUTABLE_NAME) {
+ candidates.push(join(appPath, settings.EXECUTABLE_NAME));
+ }
+ if (settings.TARGET_BUILD_DIR && settings.EXECUTABLE_PATH) {
+ candidates.push(join(settings.TARGET_BUILD_DIR, settings.EXECUTABLE_PATH));
+ }
+ if (settings.PRODUCT_NAME) {
+ candidates.push(join(appPath, `${settings.PRODUCT_NAME}.debug.dylib`));
+ }
+ return [...new Set(candidates)];
+}
+
+async function applicationBinaries(appPath, settings) {
+ let candidates = applicationBinaryCandidates(appPath, settings);
+ let entries = await readdir(appPath, { withFileTypes: true });
+ for (let entry of entries) {
+ if (entry.isFile() && entry.name.endsWith('.dylib')) {
+ candidates.push(join(appPath, entry.name));
+ }
+ }
+
+ let binaries = [];
+ for (let candidate of new Set(candidates)) {
+ if (await pathExists(candidate)) {
+ binaries.push(candidate);
+ }
+ }
+ return binaries;
+}
+
+async function discoverRegistries(appPath, settings) {
+ let registries = new Set();
+ for (let binary of await applicationBinaries(appPath, settings)) {
+ let result = await runCommand('nm', ['-j', binary], {
+ allowFailure: true,
+ });
+ for (let registry of parseRegistryTypes(result.stdout)) {
+ registries.add(registry);
+ }
+ }
+ return [...registries].sort();
+}
+
+function previewRuntimeSetupError() {
+ return new Error(
+ 'VizzlyPreviewRuntime is not linked and embedded in the app. Add the ' +
+ 'VizzlyPreviewRuntime Swift package product to the app target, choose ' +
+ 'Embed & Sign, import VizzlyPreviewRuntime, and call ' +
+ 'VizzlyPreviewRuntime.install() from the app initializer.'
+ );
+}
+
+export async function assertPreviewRuntimeIntegrated(appPath, settings) {
+ let frameworkBinary = join(
+ appPath,
+ 'Frameworks',
+ 'VizzlyPreviewRuntime.framework',
+ 'VizzlyPreviewRuntime'
+ );
+ if (!(await pathExists(frameworkBinary))) {
+ throw previewRuntimeSetupError();
+ }
+
+ let linked = false;
+ for (let binary of await applicationBinaries(appPath, settings)) {
+ let result = await runCommand('otool', ['-L', binary], {
+ allowFailure: true,
+ });
+ if (result.stdout.includes(previewRuntimeInstallName)) {
+ linked = true;
+ break;
+ }
+ }
+ if (!linked) {
+ throw previewRuntimeSetupError();
+ }
+
+ let symbols = await runCommand('nm', ['-j', frameworkBinary], {
+ allowFailure: true,
+ });
+ if (
+ !symbols.stdout.includes('_VizzlyPreviewRuntimeStart') ||
+ !symbols.stdout.includes('_VizzlyPreviewInitializerReplacement')
+ ) {
+ throw new Error(
+ 'The embedded VizzlyPreviewRuntime is not compatible with preview ' +
+ 'capture. Update the Vizzly Swift package dependency and rebuild.'
+ );
+ }
+}
+
+function slug(value) {
+ let result = value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+ return result || 'unnamed-preview';
+}
+
+async function captureRegistry({
+ registryType,
+ index,
+ device,
+ bundleId,
+ containerPath,
+ outputPath,
+ captureTimeout,
+}) {
+ let runtimeFilename = 'vizzly-preview.png';
+ let runtimePath = join(containerPath, 'Documents', runtimeFilename);
+ await rm(runtimePath, { force: true });
+
+ let result;
+ try {
+ result = await runCommand(
+ 'xcrun',
+ [
+ 'simctl',
+ 'launch',
+ '--console',
+ '--terminate-running-process',
+ device,
+ bundleId,
+ ],
+ {
+ allowFailure: true,
+ timeoutMs: captureTimeout,
+ env: {
+ ...process.env,
+ SIMCTL_CHILD_VIZZLY_REGISTRY_TYPE: registryType,
+ SIMCTL_CHILD_VIZZLY_OUTPUT_FILENAME: runtimeFilename,
+ },
+ }
+ );
+ } catch (error) {
+ if (error.code !== 'ETIMEDOUT') {
+ throw error;
+ }
+
+ return {
+ failure: {
+ exitCode: null,
+ id: createHash('sha256')
+ .update(registryType)
+ .digest('hex')
+ .slice(0, 16),
+ index: index + 1,
+ message: error.message,
+ name: null,
+ registryType,
+ signal: null,
+ },
+ };
+ }
+ let events = parseRuntimeEvents(`${result.stdout}\n${result.stderr}`);
+ let resolved = events.find(event => event.type === 'preview-resolved');
+ let completed = events.find(event => event.type === 'capture-complete');
+ let failed = events.find(event => event.type === 'capture-failed');
+ if (failed || !resolved || !completed || !(await pathExists(runtimePath))) {
+ let reason = failed?.message;
+ if (!reason && result.signal) {
+ reason = `The app was terminated by ${result.signal}`;
+ }
+ if (!reason && result.exitCode) {
+ reason = `The app exited with status ${result.exitCode}`;
+ }
+ reason ??= 'The app exited without capture completion';
+
+ return {
+ failure: {
+ exitCode: result.exitCode,
+ id: createHash('sha256')
+ .update(registryType)
+ .digest('hex')
+ .slice(0, 16),
+ index: index + 1,
+ message: reason,
+ name: failed?.name ?? resolved?.name ?? null,
+ registryType,
+ signal: result.signal,
+ },
+ };
+ }
+
+ let filename = `${String(index + 1).padStart(3, '0')}-${slug(resolved.name)}.png`;
+ let artifactPath = join(outputPath, filename);
+ await copyFile(runtimePath, artifactPath);
+ let buffer = await readFile(artifactPath);
+ let metadata = readPngMetadata(buffer);
+
+ return {
+ preview: {
+ id: createHash('sha256').update(registryType).digest('hex').slice(0, 16),
+ name: resolved.name,
+ registryType,
+ viewType: resolved.viewType,
+ file: filename,
+ ...metadata,
+ },
+ };
+}
+
+export async function runPreviewCapture({
+ container: containerInput,
+ scheme,
+ device,
+ configuration = 'Debug',
+ outputPath: outputInput,
+ captureTimeout = 30_000,
+ onProgress = () => {},
+ onFailure = () => {},
+}) {
+ let temporaryPath;
+ let stagingPath;
+
+ try {
+ let container = await resolveContainer(containerInput);
+ let outputPath = resolve(outputInput);
+ let outputParent = dirname(outputPath);
+ await mkdir(outputParent, { recursive: true });
+ await validateOutputPath(outputPath);
+ temporaryPath = await mkdtemp(join(tmpdir(), 'vizzly-previews-'));
+ stagingPath = await mkdtemp(join(outputParent, '.vizzly-previews-'));
+
+ let xcodeVersion = await assertSupportedToolchain();
+ let selectedScheme = await resolveScheme(container, scheme);
+ let resolvedScheme = selectedScheme.name;
+ let schemeAction = selectionAction(selectedScheme.selection);
+ onProgress(`${schemeAction} Xcode scheme: ${resolvedScheme}`);
+ let simulator = await resolveSimulator(device);
+ let resolvedDevice = simulator.udid;
+ let simulatorAction = selectionAction(simulator.selection);
+ onProgress(
+ `${simulatorAction} booted iOS Simulator: ${formatSimulator(simulator)}`
+ );
+ let derivedDataPath = join(temporaryPath, 'DerivedData');
+ let { appPath, settings } = await buildApplication({
+ container,
+ scheme: resolvedScheme,
+ device: resolvedDevice,
+ configuration,
+ derivedDataPath,
+ });
+ await assertPreviewRuntimeIntegrated(appPath, settings);
+ onProgress('Verified linked Vizzly preview runtime');
+ let registryTypes = await discoverRegistries(appPath, settings);
+ if (registryTypes.length === 0) {
+ throw new Error(
+ `No stock #Preview declarations were found in ${resolvedScheme}`
+ );
+ }
+ onProgress(
+ `Discovered ${registryTypes.length} stock #Preview declarations`
+ );
+
+ await runCommand('xcrun', ['simctl', 'install', resolvedDevice, appPath]);
+
+ let bundleId = settings.PRODUCT_BUNDLE_IDENTIFIER;
+ let containerResult = await runCommand('xcrun', [
+ 'simctl',
+ 'get_app_container',
+ resolvedDevice,
+ bundleId,
+ 'data',
+ ]);
+ let dataContainerPath = containerResult.stdout.trim();
+ let previews = [];
+ let failures = [];
+ for (let [index, registryType] of registryTypes.entries()) {
+ let capture = await captureRegistry({
+ registryType,
+ index,
+ device: resolvedDevice,
+ bundleId,
+ containerPath: dataContainerPath,
+ outputPath: stagingPath,
+ captureTimeout,
+ });
+ if (capture.failure) {
+ failures.push(capture.failure);
+ let label = capture.failure.name
+ ? `"${capture.failure.name}"`
+ : String(capture.failure.index);
+ onFailure(`Preview ${label} failed: ${capture.failure.message}`);
+ continue;
+ }
+
+ let preview = capture.preview;
+ previews.push(preview);
+ onProgress(`Captured ${preview.name}`);
+ }
+
+ let manifest = {
+ protocolVersion: 1,
+ xcodeVersion,
+ container,
+ scheme: resolvedScheme,
+ device: resolvedDevice,
+ simulator,
+ configuration,
+ outputPath,
+ previews,
+ failures,
+ };
+ await writeFile(
+ join(stagingPath, 'manifest.json'),
+ `${JSON.stringify(manifest, null, 2)}\n`
+ );
+ await replaceOutputDirectory(stagingPath, outputPath);
+ stagingPath = undefined;
+ return manifest;
+ } catch (error) {
+ throw new Error(`Swift preview capture failed: ${error.message}`, {
+ cause: error,
+ });
+ } finally {
+ if (temporaryPath) {
+ await rm(temporaryPath, { recursive: true, force: true });
+ }
+ if (stagingPath) {
+ await rm(stagingPath, { recursive: true, force: true });
+ }
+ }
+}
diff --git a/clients/swift/src/upload.js b/clients/swift/src/upload.js
new file mode 100644
index 00000000..cdc0bd6b
--- /dev/null
+++ b/clients/swift/src/upload.js
@@ -0,0 +1,222 @@
+import { access, readFile } from 'node:fs/promises';
+import { dirname, join, parse } from 'node:path';
+
+async function pathExists(path) {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function readServerUrl(startDir) {
+ let currentDir = startDir;
+ let root = parse(currentDir).root;
+
+ while (currentDir !== root) {
+ let serverPath = join(currentDir, '.vizzly', 'server.json');
+ if (await pathExists(serverPath)) {
+ try {
+ let server = JSON.parse(await readFile(serverPath, 'utf8'));
+ let port = Number(server.port);
+ if (Number.isInteger(port) && port > 0) {
+ return `http://localhost:${port}`;
+ }
+ } catch {
+ // Keep searching when a stale or partial server file is present.
+ }
+ }
+ currentDir = dirname(currentDir);
+ }
+
+ return null;
+}
+
+export async function findLocalTddServer(startDirectories) {
+ let checkedUrls = new Set();
+
+ for (let startDir of startDirectories) {
+ let serverUrl = await readServerUrl(startDir);
+ if (!serverUrl || checkedUrls.has(serverUrl)) {
+ continue;
+ }
+ checkedUrls.add(serverUrl);
+
+ try {
+ let response = await fetch(`${serverUrl}/health`, {
+ signal: AbortSignal.timeout(2_000),
+ });
+ if (response.ok) {
+ return serverUrl;
+ }
+ } catch {
+ // A stale server file is not an active TDD session.
+ }
+ }
+
+ return null;
+}
+
+export function hasApiToken(config = {}, env = process.env) {
+ return Boolean(config.apiKey || env.VIZZLY_TOKEN);
+}
+
+export function buildCloudRunOptions(vizzlyConfig = {}, gitInfo = {}) {
+ let runOptions = {
+ port: vizzlyConfig.server?.port || 47392,
+ timeout: vizzlyConfig.server?.timeout || 30_000,
+ buildName:
+ vizzlyConfig.build?.name ||
+ gitInfo.buildName ||
+ `SwiftUI Previews ${new Date().toISOString()}`,
+ branch: gitInfo.branch || 'main',
+ commit: gitInfo.commit,
+ message: gitInfo.message,
+ environment: vizzlyConfig.build?.environment,
+ eager: vizzlyConfig.eager || false,
+ allowNoToken: false,
+ wait: false,
+ uploadAll: false,
+ pullRequestNumber: gitInfo.prNumber,
+ parallelId: vizzlyConfig.parallelId,
+ };
+
+ if (vizzlyConfig.comparison?.threshold != null) {
+ runOptions.threshold = vizzlyConfig.comparison.threshold;
+ }
+ if (vizzlyConfig.comparison?.minClusterSize != null) {
+ runOptions.minClusterSize = vizzlyConfig.comparison.minClusterSize;
+ }
+
+ return runOptions;
+}
+
+function previewNames(manifest) {
+ let baseNames = manifest.previews.map(
+ preview => `${manifest.scheme} - ${preview.name}`
+ );
+ let baseCounts = new Map();
+ for (let name of baseNames) {
+ baseCounts.set(name, (baseCounts.get(name) ?? 0) + 1);
+ }
+
+ let qualifiedNames = manifest.previews.map((preview, index) => {
+ let baseName = baseNames[index];
+ return baseCounts.get(baseName) === 1
+ ? baseName
+ : `${baseName} - ${preview.viewType}`;
+ });
+ let qualifiedCounts = new Map();
+ for (let name of qualifiedNames) {
+ qualifiedCounts.set(name, (qualifiedCounts.get(name) ?? 0) + 1);
+ }
+
+ return qualifiedNames.map((name, index) =>
+ qualifiedCounts.get(name) === 1
+ ? name
+ : `${name} - ${manifest.previews[index].id}`
+ );
+}
+
+function safeScreenshotName(name, previewId) {
+ let safeName = name
+ .replace(/\s*[\\/]\s*/g, ' - ')
+ .replace(/\.{2,}/g, '.')
+ .replace(/[^a-zA-Z0-9._ -]/g, '_')
+ .replace(/\s+/g, ' ');
+ if (safeName.startsWith('.')) {
+ safeName = `Preview ${safeName}`;
+ }
+ if (safeName.length > 255) {
+ safeName = `${safeName.slice(0, 236).trim()} - ${previewId}`;
+ }
+ return safeName;
+}
+
+function runtimeVersion(runtime) {
+ return runtime?.replace(/^iOS\s+/, '') ?? null;
+}
+
+function shouldFailOnDiff(env = process.env) {
+ return env.VIZZLY_FAIL_ON_DIFF === 'true' || env.VIZZLY_FAIL_ON_DIFF === '1';
+}
+
+export function buildPreviewUploadRecords(manifest) {
+ let names = previewNames(manifest).map((name, index) =>
+ safeScreenshotName(name, manifest.previews[index].id)
+ );
+ let nameCounts = new Map();
+ for (let name of names) {
+ nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
+ }
+
+ return manifest.previews.map((preview, index) => ({
+ filePath: join(manifest.outputPath, preview.file),
+ name:
+ nameCounts.get(names[index]) === 1
+ ? names[index]
+ : safeScreenshotName(`${names[index]} - ${preview.id}`, preview.id),
+ properties: {
+ browser: 'SwiftUI Preview',
+ device: manifest.simulator.name,
+ osName: 'iOS',
+ osVersion: runtimeVersion(manifest.simulator.runtime),
+ platform: 'iOS',
+ previewId: preview.id,
+ scheme: manifest.scheme,
+ viewType: preview.viewType,
+ viewport: { width: preview.width, height: preview.height },
+ xcodeVersion: manifest.xcodeVersion,
+ },
+ }));
+}
+
+export async function uploadCapturedPreviews({
+ buildId,
+ comparison = {},
+ manifest,
+ screenshotClient,
+ serverUrl,
+}) {
+ let requiredMethods = [
+ screenshotClient?.configure,
+ screenshotClient?.vizzlyFlush,
+ screenshotClient?.vizzlyScreenshot,
+ ];
+ if (requiredMethods.some(method => typeof method !== 'function')) {
+ throw new Error(
+ 'This @vizzly-testing/cli installation does not provide screenshot uploads'
+ );
+ }
+
+ screenshotClient.configure({
+ enabled: true,
+ failOnDiff: shouldFailOnDiff(),
+ serverUrl,
+ });
+ let records = buildPreviewUploadRecords(manifest);
+
+ for (let record of records) {
+ let result = await screenshotClient.vizzlyScreenshot(
+ record.name,
+ record.filePath,
+ {
+ buildId,
+ minClusterSize: comparison.minClusterSize,
+ properties: record.properties,
+ threshold: comparison.threshold,
+ }
+ );
+ if (!result) {
+ throw new Error(`Vizzly did not accept preview "${record.name}"`);
+ }
+ }
+
+ let flush = await screenshotClient.vizzlyFlush();
+ if (!flush && buildId) {
+ throw new Error('Vizzly did not finish processing the preview screenshots');
+ }
+
+ return { flush, uploaded: records.length };
+}
diff --git a/clients/swift/tests-js/index.test.js b/clients/swift/tests-js/index.test.js
new file mode 100644
index 00000000..0c2448d6
--- /dev/null
+++ b/clients/swift/tests-js/index.test.js
@@ -0,0 +1,71 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { assertCompleteCapture, resolvePreviewOptions } from '../src/index.js';
+
+describe('Swift preview CLI options', () => {
+ it('uses configured defaults when command options are omitted', () => {
+ assert.deepEqual(
+ resolvePreviewOptions(
+ {},
+ {
+ captureTimeout: 45_000,
+ configuration: 'Release',
+ device: 'CONFIGURED-DEVICE',
+ output: 'configured-output',
+ scheme: 'ConfiguredScheme',
+ upload: false,
+ }
+ ),
+ {
+ captureTimeout: 45_000,
+ configuration: 'Release',
+ device: 'CONFIGURED-DEVICE',
+ outputPath: 'configured-output',
+ scheme: 'ConfiguredScheme',
+ upload: false,
+ }
+ );
+ });
+
+ it('lets command options override configuration', () => {
+ let resolved = resolvePreviewOptions(
+ {
+ captureTimeout: 5_000,
+ configuration: 'Debug',
+ output: 'command-output',
+ },
+ {
+ captureTimeout: 45_000,
+ configuration: 'Release',
+ output: 'configured-output',
+ }
+ );
+
+ assert.equal(resolved.captureTimeout, 5_000);
+ assert.equal(resolved.configuration, 'Debug');
+ assert.equal(resolved.outputPath, 'command-output');
+ assert.equal(resolved.upload, true);
+ });
+});
+
+describe('Swift preview capture completion', () => {
+ it('accepts a complete preview set', () => {
+ assert.doesNotThrow(() =>
+ assertCompleteCapture({ failures: [], previews: [{}] })
+ );
+ });
+
+ it('fails after preserving the manifest for incomplete preview sets', () => {
+ assert.throws(
+ () =>
+ assertCompleteCapture({
+ failures: [{ name: 'Broken preview' }],
+ outputPath: '/tmp/previews',
+ previews: [{ name: 'Working preview' }],
+ }),
+ error =>
+ error.message.includes('1 of 2 SwiftUI previews failed') &&
+ error.message.includes('/tmp/previews/manifest.json')
+ );
+ });
+});
diff --git a/clients/swift/tests-js/plugin.test.js b/clients/swift/tests-js/plugin.test.js
new file mode 100644
index 00000000..16f1c55c
--- /dev/null
+++ b/clients/swift/tests-js/plugin.test.js
@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import packageJson from '../package.json' with { type: 'json' };
+import plugin from '../src/plugin.js';
+
+describe('Swift preview plugin package', () => {
+ it('publishes a CLI-discoverable plugin without build-time Swift sources', () => {
+ assert.equal(packageJson.vizzlyPlugin, './src/plugin.js');
+ assert.equal(plugin.version, packageJson.version);
+ assert.ok(!packageJson.files.includes('Sources/VizzlyPreviewRuntime'));
+ assert.ok(!packageJson.files.includes('Sources/CVizzlyPreviewRuntime'));
+ assert.ok(!packageJson.files.includes('Package.swift'));
+ assert.equal(
+ packageJson.peerDependencies['@vizzly-testing/cli'],
+ '>=0.36.0'
+ );
+ });
+
+ it('documents conservative capture defaults for vizzly init', () => {
+ assert.deepEqual(plugin.configSchema.swiftPreviews, {
+ captureTimeout: 30_000,
+ configuration: 'Debug',
+ device: null,
+ output: '.vizzly/previews',
+ scheme: null,
+ upload: true,
+ });
+ });
+});
diff --git a/clients/swift/tests-js/preview-runner.test.js b/clients/swift/tests-js/preview-runner.test.js
new file mode 100644
index 00000000..ea2be273
--- /dev/null
+++ b/clients/swift/tests-js/preview-runner.test.js
@@ -0,0 +1,242 @@
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, it } from 'node:test';
+import {
+ applicationBinaryCandidates,
+ assertPreviewRuntimeIntegrated,
+ parseBootedIOSSimulators,
+ parseRegistryTypes,
+ parseRuntimeEvents,
+ parseSchemes,
+ readPngMetadata,
+ schemeBuildsApplication,
+ selectBootedIOSSimulator,
+ selectScheme,
+} from '../src/preview-runner.js';
+
+let simulatorList = JSON.stringify({
+ devices: {
+ 'com.apple.CoreSimulator.SimRuntime.iOS-26-5': [
+ {
+ isAvailable: true,
+ name: 'iPhone 17 Pro',
+ state: 'Booted',
+ udid: 'PHONE-17-PRO',
+ },
+ ],
+ 'com.apple.CoreSimulator.SimRuntime.tvOS-26-5': [
+ {
+ isAvailable: true,
+ name: 'Apple TV 4K',
+ state: 'Booted',
+ udid: 'APPLE-TV',
+ },
+ ],
+ 'com.apple.CoreSimulator.SimRuntime.iOS-18-5': [
+ {
+ isAvailable: true,
+ name: 'iPhone 16',
+ state: 'Shutdown',
+ udid: 'SHUTDOWN-PHONE',
+ },
+ {
+ isAvailable: false,
+ name: 'Unavailable iPhone',
+ state: 'Booted',
+ udid: 'UNAVAILABLE-PHONE',
+ },
+ ],
+ },
+});
+
+describe('Swift preview runner contracts', () => {
+ it('auto-selects the only shared Xcode scheme', () => {
+ let schemes = parseSchemes(
+ JSON.stringify({ project: { schemes: ['PreviewFixture'] } })
+ );
+
+ assert.deepEqual(selectScheme(schemes), {
+ name: 'PreviewFixture',
+ selection: 'automatic',
+ });
+ });
+
+ it('distinguishes app schemes from Swift package library schemes', () => {
+ let appSettings = JSON.stringify([
+ { buildSettings: { FULL_PRODUCT_NAME: 'PreviewFixture.app' } },
+ ]);
+ let librarySettings = JSON.stringify([
+ {
+ buildSettings: { FULL_PRODUCT_NAME: 'VizzlyPreviewRuntime.framework' },
+ },
+ ]);
+
+ assert.equal(schemeBuildsApplication(appSettings), true);
+ assert.equal(schemeBuildsApplication(librarySettings), false);
+ });
+
+ it('requires an explicit scheme when an Xcode container has several', () => {
+ assert.throws(
+ () => selectScheme(['App', 'AppTests']),
+ /More than one Xcode scheme is available.*--scheme /s
+ );
+ assert.throws(
+ () => selectScheme(['App'], 'Missing'),
+ /Missing is not an available Xcode scheme/
+ );
+ });
+
+ it('finds only available, booted iOS Simulators', () => {
+ assert.deepEqual(parseBootedIOSSimulators(simulatorList), [
+ {
+ name: 'iPhone 17 Pro',
+ runtime: 'iOS 26.5',
+ udid: 'PHONE-17-PRO',
+ },
+ ]);
+ });
+
+ it('auto-selects the only booted iOS Simulator', () => {
+ assert.deepEqual(
+ selectBootedIOSSimulator(parseBootedIOSSimulators(simulatorList)),
+ {
+ name: 'iPhone 17 Pro',
+ runtime: 'iOS 26.5',
+ selection: 'automatic',
+ udid: 'PHONE-17-PRO',
+ }
+ );
+ });
+
+ it('requires an explicit choice when multiple iOS Simulators are booted', () => {
+ let simulators = [
+ {
+ name: 'iPhone 17 Pro',
+ runtime: 'iOS 26.5',
+ udid: 'PHONE-17-PRO',
+ },
+ {
+ name: 'iPad Pro 13-inch',
+ runtime: 'iOS 26.5',
+ udid: 'IPAD-PRO',
+ },
+ ];
+
+ assert.throws(
+ () => selectBootedIOSSimulator(simulators),
+ error =>
+ error.message.includes('More than one iOS Simulator is booted') &&
+ error.message.includes('iPad Pro 13-inch (iOS 26.5, IPAD-PRO)') &&
+ error.message.includes('Pass --device to choose one')
+ );
+ });
+
+ it('explains how to recover when no iOS Simulator is booted', () => {
+ assert.throws(
+ () => selectBootedIOSSimulator([]),
+ /No booted iOS Simulator was found.*Open Simulator or boot one from Xcode/
+ );
+ });
+
+ it('honors an explicitly selected booted Simulator', () => {
+ let simulators = parseBootedIOSSimulators(simulatorList);
+ assert.equal(
+ selectBootedIOSSimulator(simulators, 'PHONE-17-PRO').selection,
+ 'explicit'
+ );
+ assert.throws(
+ () => selectBootedIOSSimulator(simulators, 'NOT-BOOTED'),
+ /NOT-BOOTED is not a booted iOS Simulator/
+ );
+ });
+
+ it('discovers generated stock #Preview registry types from Mach-O symbols', () => {
+ let output = [
+ '_$s13PreviewFixture0017PreviewFixtureswift_tAFJhfMX1_0_15RegistryfMu_VMn',
+ '_main',
+ '_$s13PreviewFixture0017PreviewFixtureswift_tAFJhfMX1_0_15RegistryfMu_VMn',
+ '_$s13PreviewFixture0017PreviewFixtureswift_tAFJhfMX2_0_15RegistryfMu_VMn',
+ ].join('\n');
+
+ assert.deepEqual(parseRegistryTypes(output), [
+ '13PreviewFixture0017PreviewFixtureswift_tAFJhfMX1_0_15RegistryfMu_V',
+ '13PreviewFixture0017PreviewFixtureswift_tAFJhfMX2_0_15RegistryfMu_V',
+ ]);
+ });
+
+ it('finds the built app executable with or without a debug dylib', () => {
+ let appPath = '/tmp/Build/PreviewFixture.app';
+ let settings = {
+ EXECUTABLE_NAME: 'PreviewFixture',
+ EXECUTABLE_PATH: 'PreviewFixture.app/PreviewFixture',
+ PRODUCT_NAME: 'PreviewFixture',
+ TARGET_BUILD_DIR: '/tmp/Build',
+ };
+
+ assert.deepEqual(applicationBinaryCandidates(appPath, settings), [
+ join(appPath, 'PreviewFixture'),
+ join(appPath, 'PreviewFixture.debug.dylib'),
+ ]);
+ });
+
+ it('explains how to integrate a missing app-linked preview runtime', async () => {
+ let appPath = await mkdtemp(join(tmpdir(), 'vizzly-unlinked-app-'));
+
+ try {
+ await assert.rejects(
+ assertPreviewRuntimeIntegrated(appPath, {}),
+ error =>
+ error.message.includes(
+ 'VizzlyPreviewRuntime is not linked and embedded in the app'
+ ) &&
+ error.message.includes(
+ 'Add the VizzlyPreviewRuntime Swift package'
+ ) &&
+ error.message.includes('VizzlyPreviewRuntime.install()')
+ );
+ } finally {
+ await rm(appPath, { recursive: true, force: true });
+ }
+ });
+
+ it('ignores app logs and reads versioned runtime completion events', () => {
+ let output = [
+ 'ordinary app log',
+ 'VIZZLY_PREVIEW_EVENT {"protocolVersion":1,"type":"preview-resolved","name":"Card"}',
+ 'VIZZLY_PREVIEW_EVENT {"protocolVersion":1,"type":"capture-complete","filename":"vizzly-preview.png"}',
+ ].join('\n');
+
+ assert.deepEqual(
+ parseRuntimeEvents(output).map(event => event.type),
+ ['preview-resolved', 'capture-complete']
+ );
+ });
+
+ it('validates observable PNG dimensions and content hash', () => {
+ let png = Buffer.alloc(45);
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png);
+ png.writeUInt32BE(13, 8);
+ png.write('IHDR', 12, 'ascii');
+ png.writeUInt32BE(393, 16);
+ png.writeUInt32BE(852, 20);
+ png.writeUInt32BE(0, 33);
+ png.write('IEND', 37, 'ascii');
+
+ let metadata = readPngMetadata(png);
+ assert.equal(metadata.width, 393);
+ assert.equal(metadata.height, 852);
+ assert.match(metadata.sha256, /^[a-f0-9]{64}$/);
+ });
+
+ it('rejects a non-PNG capture', () => {
+ assert.throws(() => readPngMetadata(Buffer.from('not a png')), /valid PNG/);
+ let truncated = Buffer.concat([
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
+ Buffer.from([0, 0, 0, 13]),
+ Buffer.from('IHDR'),
+ ]);
+ assert.throws(() => readPngMetadata(truncated), /valid PNG/);
+ });
+});
diff --git a/clients/swift/tests-js/upload.test.js b/clients/swift/tests-js/upload.test.js
new file mode 100644
index 00000000..a90a0ad1
--- /dev/null
+++ b/clients/swift/tests-js/upload.test.js
@@ -0,0 +1,256 @@
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, it } from 'node:test';
+import {
+ configure,
+ vizzlyFlush,
+ vizzlyScreenshot,
+} from '../../../src/client/index.js';
+import {
+ buildCloudRunOptions,
+ buildPreviewUploadRecords,
+ findLocalTddServer,
+ uploadCapturedPreviews,
+} from '../src/upload.js';
+
+let temporaryPaths = [];
+let servers = [];
+
+afterEach(async () => {
+ await Promise.all(
+ servers.splice(0).map(server => server[Symbol.asyncDispose]())
+ );
+ await Promise.all(
+ temporaryPaths
+ .splice(0)
+ .map(path => rm(path, { recursive: true, force: true }))
+ );
+});
+
+function previewManifest(outputPath) {
+ return {
+ protocolVersion: 1,
+ xcodeVersion: '26.6',
+ scheme: 'Example',
+ simulator: {
+ name: 'iPhone 17 Pro',
+ runtime: 'iOS 26.4',
+ udid: 'SIMULATOR-UDID',
+ },
+ outputPath,
+ previews: [
+ {
+ id: 'first-id',
+ name: 'Card',
+ viewType: 'Example.Card',
+ file: '001-card.png',
+ width: 1206,
+ height: 2622,
+ },
+ {
+ id: 'second-id',
+ name: 'Card',
+ viewType: 'Example.CompactCard',
+ file: '002-card.png',
+ width: 900,
+ height: 700,
+ },
+ ],
+ };
+}
+
+async function startServer(handler) {
+ let server = createServer(handler);
+ await new Promise(resolvePromise => server.listen(0, resolvePromise));
+ servers.push(server);
+ let address = server.address();
+ return `http://127.0.0.1:${address.port}`;
+}
+
+describe('Swift preview uploads', () => {
+ it('builds stable names and native preview metadata', () => {
+ let records = buildPreviewUploadRecords(previewManifest('/tmp/previews'));
+
+ assert.deepEqual(
+ records.map(record => record.name),
+ ['Example - Card - Example.Card', 'Example - Card - Example.CompactCard']
+ );
+ assert.deepEqual(records[0].properties, {
+ browser: 'SwiftUI Preview',
+ device: 'iPhone 17 Pro',
+ osName: 'iOS',
+ osVersion: '26.4',
+ platform: 'iOS',
+ previewId: 'first-id',
+ scheme: 'Example',
+ viewType: 'Example.Card',
+ viewport: { width: 1206, height: 2622 },
+ xcodeVersion: '26.6',
+ });
+ });
+
+ it('normalizes Xcode preview names for the Vizzly screenshot contract', () => {
+ let manifest = previewManifest('/tmp/previews');
+ manifest.previews[0].name = 'Card / Dark';
+
+ let [record] = buildPreviewUploadRecords(manifest);
+
+ assert.equal(record.name, 'Example - Card - Dark');
+ });
+
+ it('keeps names unique when different Xcode names normalize alike', () => {
+ let manifest = previewManifest('/tmp/previews');
+ manifest.previews[0].name = 'Card / Dark';
+ manifest.previews[1].name = 'Card \\ Dark';
+
+ let records = buildPreviewUploadRecords(manifest);
+
+ assert.deepEqual(
+ records.map(record => record.name),
+ ['Example - Card - Dark - first-id', 'Example - Card - Dark - second-id']
+ );
+ });
+
+ it('builds cloud lifecycle options from Vizzly and git configuration', () => {
+ let options = buildCloudRunOptions(
+ {
+ build: { environment: 'test', name: 'Native previews' },
+ comparison: { minClusterSize: 4, threshold: 1.5 },
+ parallelId: 'ios-shard',
+ server: { port: 48000, timeout: 60_000 },
+ },
+ {
+ branch: 'preview-sdk',
+ commit: 'abc123',
+ message: 'Render stock previews',
+ prNumber: 42,
+ }
+ );
+
+ assert.deepEqual(options, {
+ allowNoToken: false,
+ branch: 'preview-sdk',
+ buildName: 'Native previews',
+ commit: 'abc123',
+ eager: false,
+ environment: 'test',
+ message: 'Render stock previews',
+ minClusterSize: 4,
+ parallelId: 'ios-shard',
+ port: 48000,
+ pullRequestNumber: 42,
+ threshold: 1.5,
+ timeout: 60_000,
+ uploadAll: false,
+ wait: false,
+ });
+ });
+
+ it('discovers only a live TDD server', async () => {
+ let root = await mkdtemp(join(tmpdir(), 'vizzly-swift-upload-'));
+ temporaryPaths.push(root);
+ let nested = join(root, 'ios', 'Example');
+ await mkdir(join(root, '.vizzly'), { recursive: true });
+ await mkdir(nested, { recursive: true });
+ let serverUrl = await startServer((request, response) => {
+ response.writeHead(request.url === '/health' ? 200 : 404);
+ response.end();
+ });
+ let port = Number(new URL(serverUrl).port);
+ await writeFile(
+ join(root, '.vizzly', 'server.json'),
+ JSON.stringify({ port: String(port) })
+ );
+
+ assert.equal(
+ await findLocalTddServer([nested]),
+ `http://localhost:${port}`
+ );
+
+ await servers.pop()[Symbol.asyncDispose]();
+ assert.equal(await findLocalTddServer([nested]), null);
+ });
+
+ it('uploads every rendered PNG through the public CLI client', async () => {
+ let requests = [];
+ let serverUrl = await startServer((request, response) => {
+ let chunks = [];
+ request.on('data', chunk => chunks.push(chunk));
+ request.on('end', () => {
+ requests.push({
+ body: JSON.parse(Buffer.concat(chunks).toString() || '{}'),
+ url: request.url,
+ });
+ response.writeHead(200, { 'Content-Type': 'application/json' });
+ response.end(
+ JSON.stringify(
+ request.url === '/flush'
+ ? { success: true, summary: { total: 2 } }
+ : { success: true, status: 'new' }
+ )
+ );
+ });
+ });
+ let manifest = previewManifest('/tmp/previews');
+
+ let result = await uploadCapturedPreviews({
+ buildId: 'build-123',
+ comparison: { minClusterSize: 3, threshold: 2.5 },
+ manifest,
+ screenshotClient: { configure, vizzlyFlush, vizzlyScreenshot },
+ serverUrl,
+ });
+
+ assert.equal(result.uploaded, 2);
+ assert.equal(result.flush.summary.total, 2);
+ assert.deepEqual(
+ requests.map(request => request.url),
+ ['/screenshot', '/screenshot', '/flush']
+ );
+ assert.equal(requests[0].body.buildId, 'build-123');
+ assert.equal(requests[0].body.name, 'Example - Card - Example.Card');
+ assert.equal(requests[0].body.type, 'file-path');
+ assert.equal(requests[0].body.threshold, 2.5);
+ assert.equal(requests[0].body.minClusterSize, 3);
+ assert.equal(requests[0].body.properties.threshold, undefined);
+ assert.equal(requests[0].body.properties.minClusterSize, undefined);
+ });
+
+ it('honors both supported fail-on-diff environment values', async () => {
+ let receivedValues = [];
+ let screenshotClient = {
+ configure(options) {
+ receivedValues.push(options.failOnDiff);
+ },
+ async vizzlyFlush() {
+ return { success: true };
+ },
+ async vizzlyScreenshot() {
+ return { success: true };
+ },
+ };
+ let originalValue = process.env.VIZZLY_FAIL_ON_DIFF;
+
+ try {
+ for (let value of ['true', '1']) {
+ process.env.VIZZLY_FAIL_ON_DIFF = value;
+ await uploadCapturedPreviews({
+ manifest: previewManifest('/tmp/previews'),
+ screenshotClient,
+ serverUrl: 'http://localhost:47392',
+ });
+ }
+ } finally {
+ if (originalValue === undefined) {
+ delete process.env.VIZZLY_FAIL_ON_DIFF;
+ } else {
+ process.env.VIZZLY_FAIL_ON_DIFF = originalValue;
+ }
+ }
+
+ assert.deepEqual(receivedValues, [true, true]);
+ });
+});
diff --git a/package.json b/package.json
index afd59def..bf2e4e9d 100644
--- a/package.json
+++ b/package.json
@@ -72,17 +72,18 @@
"dev:reporter": "cd src/reporter && vite --config vite.dev.config.js",
"test:types": "tsd",
"prepublishOnly": "pnpm run build",
- "test": "node --experimental-test-coverage --test --test-concurrency=1 --test-reporter=spec $(find tests -name '*.test.js')",
+ "test": "node --experimental-test-coverage --test --test-concurrency=1 --test-reporter=spec $(find tests clients/swift/tests-js -name '*.test.js')",
"test:watch": "node --test --test-reporter=spec --watch $(find tests -name '*.test.js')",
"test:reporter": "playwright test --config=tests/reporter/playwright.config.js",
"test:reporter:visual": "node bin/vizzly.js tdd run \"pnpm run test:reporter\" --no-open",
"test:swift:e2e": "pnpm run build && node clients/swift/scripts/run-e2e.js",
+ "test:swift:previews:e2e": "node clients/swift/scripts/run-preview-e2e.js",
"test:tui": "node --test --test-reporter=spec tests/tui/*.test.js",
"test:tui:docker": "./tests/tui/run-tui-tests.sh",
- "lint": "biome check src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
- "lint:fix": "biome check --write --unsafe src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
- "format": "biome format --write src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
- "format:check": "biome format src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
+ "lint": "biome check src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin clients/swift/src clients/swift/tests-js clients/swift/scripts",
+ "lint:fix": "biome check --write --unsafe src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin clients/swift/src clients/swift/tests-js clients/swift/scripts",
+ "format": "biome format --write src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin clients/swift/src clients/swift/tests-js clients/swift/scripts",
+ "format:check": "biome format src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin clients/swift/src clients/swift/tests-js clients/swift/scripts",
"fix": "pnpm run format && pnpm run lint:fix"
},
"engines": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7e6d1ad9..846dc651 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -302,6 +302,15 @@ importers:
specifier: ^6.0.1
version: 6.1.3
+ clients/swift:
+ devDependencies:
+ '@biomejs/biome':
+ specifier: ^2.5.10
+ version: 2.5.11
+ '@vizzly-testing/cli':
+ specifier: workspace:*
+ version: link:../..
+
clients/storybook:
dependencies:
'@vizzly-testing/cli':