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':