diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 836e501..07bf980 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -255,6 +255,21 @@ jobs: - '.github/workflows/ci.yml' - '.github/workflows/release-vitest-client.yml' + changes-swift: + runs-on: ubuntu-latest + outputs: + swift: ${{ steps.filter.outputs.swift }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + swift: + - 'clients/swift/**' + - '.github/workflows/ci.yml' + - '.github/workflows/release-swift-client.yml' + test-storybook-client: runs-on: ubuntu-latest needs: [lint, changes-storybook] @@ -400,9 +415,32 @@ jobs: VIZZLY_COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message || github.event.head_commit.message }} VIZZLY_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.id }} + test-swift-client: + runs-on: macos-latest + needs: [lint, changes-swift] + if: needs.changes-swift.outputs.swift == 'true' + + strategy: + matrix: + xcode-version: ['16.2', '16.4'] + + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode-version }}.app + + - name: Build Swift package + working-directory: ./clients/swift + run: swift build + + - name: Run Swift tests + working-directory: ./clients/swift + run: swift test + ci-check: runs-on: ubuntu-latest - needs: [lint, test, test-site, test-reporter, changes-ruby, test-ruby-client, changes-storybook, test-storybook-client, changes-static-site, test-static-site-client, changes-vitest, test-vitest-client] + needs: [lint, test, test-site, test-reporter, changes-ruby, test-ruby-client, changes-storybook, test-storybook-client, changes-static-site, test-static-site-client, changes-vitest, test-vitest-client, changes-swift, test-swift-client] if: always() steps: - name: Check if all jobs passed @@ -434,4 +472,9 @@ jobs: exit 1 fi + if [[ "${{ needs.changes-swift.outputs.swift }}" == "true" && "${{ needs.test-swift-client.result }}" == "failure" ]]; then + echo "Swift client tests failed" + exit 1 + fi + echo "All jobs passed" diff --git a/.github/workflows/release-swift-client.yml b/.github/workflows/release-swift-client.yml new file mode 100644 index 0000000..108cae4 --- /dev/null +++ b/.github/workflows/release-swift-client.yml @@ -0,0 +1,177 @@ +name: Release Swift Client + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Version bump type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +jobs: + release: + runs-on: macos-latest + permissions: + contents: write + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_16.4.app + + - name: Configure git + run: | + git config --local user.email "${{ secrets.GIT_USER_EMAIL }}" + git config --local user.name "${{ secrets.GIT_USER_NAME }}" + + - name: Get current version + id: current_version + working-directory: ./clients/swift + run: | + # Extract version from CHANGELOG.md + CURRENT_VERSION=$(grep -m 1 "## \[" CHANGELOG.md | sed 's/## \[\(.*\)\].*/\1/') + if [ -z "$CURRENT_VERSION" ]; then + CURRENT_VERSION="0.1.0" + fi + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + + - name: Calculate new version + id: new_version + 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 + 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 + + - name: Generate changelog with Claude + id: changelog + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + Generate release notes for the Vizzly Swift SDK v${{ steps.new_version.outputs.version }}. + + Context: + - Client location: `clients/swift/` + - Previous tag: `swift/v${{ steps.current_version.outputs.version }}` + - New version: `${{ steps.new_version.outputs.version }}` + - This is a monorepo with multiple clients and a CLI + + Instructions: + 1. Use git commands (via Bash tool) to get commits since last swift/* tag + 2. Analyze which commits are relevant to `clients/swift/` + 3. Read the code changes if needed to understand impact + 4. Generate user-friendly release notes with categories: Added, Changed, Fixed + 5. Focus on user-facing changes only + 6. If no relevant changes, output: "No changes to Swift client in this release" + + Save the changelog to `clients/swift/CHANGELOG-RELEASE.md` with this format: + + ## What's Changed + + ### Added + - New features + + ### Changed + - Breaking or notable changes + + ### Fixed + - Bug fixes + + **Full Changelog**: https://github.com/vizzly-testing/cli/compare/swift/v${{ steps.current_version.outputs.version }}...swift/v${{ steps.new_version.outputs.version }} + claude_args: '--allowed-tools "Bash(git:*)"' + + - name: Update CHANGELOG.md + working-directory: ./clients/swift + run: | + # Check if changelog was generated successfully + if [ ! -f CHANGELOG-RELEASE.md ]; then + echo "Warning: CHANGELOG-RELEASE.md not found, creating fallback changelog" + cat > CHANGELOG-RELEASE.md << 'EOF' + ## What's Changed + + Release v${{ steps.new_version.outputs.version }} + + See the full diff for detailed changes. + EOF + fi + + # Prepend new release to CHANGELOG.md + echo -e "# Vizzly Swift SDK Changelog\n" > CHANGELOG-NEW.md + echo "## [${{ steps.new_version.outputs.version }}] - $(date +%Y-%m-%d)" >> CHANGELOG-NEW.md + echo "" >> CHANGELOG-NEW.md + cat CHANGELOG-RELEASE.md >> CHANGELOG-NEW.md + echo "" >> CHANGELOG-NEW.md + tail -n +2 CHANGELOG.md >> CHANGELOG-NEW.md + mv CHANGELOG-NEW.md CHANGELOG.md + rm CHANGELOG-RELEASE.md + + - name: Build Swift package + working-directory: ./clients/swift + run: swift build + + - name: Run tests + working-directory: ./clients/swift + run: swift test + + - name: Commit and push changes + run: | + git add 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 }}" + + - name: Read changelog for release + id: release_notes + working-directory: ./clients/swift + run: | + # Extract just this version's changelog + CHANGELOG=$(sed -n '/## \[${{ steps.new_version.outputs.version }}\]/,/## \[/p' CHANGELOG.md | sed '$ d') + { + echo 'notes<> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.new_version.outputs.tag }} + name: 📱 Swift SDK v${{ steps.new_version.outputs.version }} + body: ${{ steps.release_notes.outputs.notes }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/clients/swift/.gitignore b/clients/swift/.gitignore new file mode 100644 index 0000000..70e1b1c --- /dev/null +++ b/clients/swift/.gitignore @@ -0,0 +1,16 @@ +# Swift Package Manager +.build/ +*.xcodeproj +.swiftpm/ + +# Xcode +xcuserdata/ +*.xcworkspace +!default.xcworkspace + +# macOS +.DS_Store + +# Swift +*.swiftmodule +*.swiftdoc diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md new file mode 100644 index 0000000..aca14b7 --- /dev/null +++ b/clients/swift/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to the Vizzly Swift SDK will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial Swift SDK implementation +- Core `VizzlyClient` class with HTTP communication +- Auto-discovery of TDD server via `.vizzly/server.json` +- XCTest integration extensions for `XCUIApplication`, `XCUIElement`, and `XCTestCase` +- Automatic metadata capture (platform, device, viewport) +- iOS and macOS support +- Environment variable configuration support +- Graceful error handling and client disabling +- Comprehensive example UI tests +- Full documentation and README diff --git a/clients/swift/Example/ExampleUITests.swift b/clients/swift/Example/ExampleUITests.swift new file mode 100644 index 0000000..dc16603 --- /dev/null +++ b/clients/swift/Example/ExampleUITests.swift @@ -0,0 +1,213 @@ +import XCTest +import Vizzly + +/// Example UI tests demonstrating Vizzly integration +/// +/// To run these tests with Vizzly: +/// +/// 1. Start TDD server: +/// ```bash +/// cd /path/to/your/ios/project +/// vizzly tdd start +/// ``` +/// +/// 2. Run UI tests in Xcode or via command line: +/// ```bash +/// xcodebuild test -scheme YourApp -destination 'platform=iOS Simulator,name=iPhone 15' +/// ``` +/// +/// 3. View results in dashboard: +/// Open http://localhost:47392/dashboard +/// +final class ExampleUITests: XCTestCase { + + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = true + app.launch() + + // Check if Vizzly is ready + if VizzlyClient.shared.isReady { + print("✓ Vizzly is ready!") + print(" Info: \(VizzlyClient.shared.info)") + } else { + print("⚠️ Vizzly not available - screenshots will be skipped") + } + } + + // MARK: - Basic Screenshot Examples + + func testHomeScreen() throws { + // Wait for home screen to load + let welcomeText = app.staticTexts["Welcome"] + XCTAssertTrue(welcomeText.waitForExistence(timeout: 5)) + + // Capture full screen screenshot + app.vizzlyScreenshot(name: "home-screen") + } + + func testLoginForm() throws { + // Navigate to login screen + app.buttons["Login"].tap() + + // Wait for login form + let emailField = app.textFields["Email"] + XCTAssertTrue(emailField.waitForExistence(timeout: 5)) + + // Capture login form + app.vizzlyScreenshot( + name: "login-form", + properties: [ + "screen": "login", + "state": "empty" + ] + ) + + // Fill in form + emailField.tap() + emailField.typeText("test@vizzly.dev") + + let passwordField = app.secureTextFields["Password"] + passwordField.tap() + passwordField.typeText("password123") + + // Capture filled form + app.vizzlyScreenshot( + name: "login-form-filled", + properties: [ + "screen": "login", + "state": "filled" + ] + ) + } + + // MARK: - Element Screenshot Examples + + func testNavigationBar() throws { + let navbar = app.navigationBars.firstMatch + XCTAssertTrue(navbar.exists) + + // Capture just the navigation bar + navbar.vizzlyScreenshot( + name: "navigation-bar", + properties: ["component": "navbar"] + ) + } + + func testProductCard() throws { + // Find product card + let productCard = app.otherElements["ProductCard"].firstMatch + XCTAssertTrue(productCard.waitForExistence(timeout: 5)) + + // Capture individual component + productCard.vizzlyScreenshot( + name: "product-card", + properties: [ + "component": "product-card", + "variant": "default" + ] + ) + } + + // MARK: - Advanced Examples + + func testDarkMode() throws { + // Enable dark mode (implementation depends on your app) + app.buttons["Settings"].tap() + app.switches["Dark Mode"].tap() + + // Navigate back to home + app.navigationBars.buttons.firstMatch.tap() + + // Capture dark mode screenshot + app.vizzlyScreenshot( + name: "home-screen-dark", + properties: [ + "theme": "dark", + "screen": "home" + ] + ) + } + + func testResponsiveLayout() throws { + // Test different device orientations + XCUIDevice.shared.orientation = .portrait + sleep(1) // Wait for orientation change + + app.vizzlyScreenshot( + name: "home-portrait", + properties: ["orientation": "portrait"] + ) + + XCUIDevice.shared.orientation = .landscapeLeft + sleep(1) + + app.vizzlyScreenshot( + name: "home-landscape", + properties: ["orientation": "landscape"] + ) + + // Reset orientation + XCUIDevice.shared.orientation = .portrait + } + + func testScrollableContent() throws { + let scrollView = app.scrollViews.firstMatch + XCTAssertTrue(scrollView.exists) + + // Capture initial viewport + app.vizzlyScreenshot( + name: "feed-top", + properties: ["scroll": "top"] + ) + + // Scroll down + scrollView.swipeUp() + scrollView.swipeUp() + + // Capture scrolled content + app.vizzlyScreenshot( + name: "feed-scrolled", + properties: ["scroll": "middle"] + ) + } + + // MARK: - Using XCTestCase Extension + + func testWithTestCaseExtension() throws { + // You can also use the XCTestCase extension + vizzlyScreenshot( + name: "home-via-test-case", + app: app, + properties: ["method": "testcase-extension"] + ) + } + + // MARK: - Custom Threshold Example + + func testWithCustomThreshold() throws { + // Allow up to 5% pixel difference + app.vizzlyScreenshot( + name: "animation-test", + threshold: 5, + properties: ["note": "Animation may cause slight differences"] + ) + } + + // MARK: - Direct Client Usage + + func testDirectClientUsage() throws { + let screenshot = app.screenshot() + + VizzlyClient.shared.screenshot( + name: "direct-client-usage", + image: screenshot.pngRepresentation, + properties: [ + "method": "direct-client", + "custom": "properties" + ], + threshold: 0 + ) + } +} diff --git a/clients/swift/INTEGRATION.md b/clients/swift/INTEGRATION.md new file mode 100644 index 0000000..56a3529 --- /dev/null +++ b/clients/swift/INTEGRATION.md @@ -0,0 +1,472 @@ +# iOS Integration Guide + +Complete guide for adding Vizzly to your iOS app's UI tests. + +## Step-by-Step Integration + +### 1. Install Vizzly CLI + +The CLI provides the TDD server and cloud upload capabilities. + +```bash +npm 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. **Important**: Add the package to your **UI Test target** (not the main app target) + +#### 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: + +```bash +cd /path/to/MyiOSApp +``` + +Create a `vizzly.config.js` file (optional but recommended): + +```javascript +export default { + // Screenshot threshold (0-100) + threshold: 0, + + // TDD server port + port: 47392, + + // Baseline directory + baselineDir: '.vizzly/baselines' +} +``` + +### 4. Start TDD Server + +```bash +vizzly tdd start +``` + +This starts a local server at `http://localhost:47392` that will: +- Receive screenshots from your tests +- Compare them against baselines +- Serve a dashboard at `http://localhost:47392/dashboard` + +### 5. Write UI Tests with Vizzly + +Create or update your UI test file: + +```swift +import XCTest +import Vizzly + +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 + ] + ) + } +} +``` + +### 6. Run Tests + +#### Via Xcode + +1. Select your UI test scheme +2. Choose a simulator/device +3. Press `Cmd+U` or Product → Test + +#### Via Command Line + +```bash +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 +``` + +**Important**: Commit `.vizzly/baselines/` so your team shares the same baseline screenshots. + +## Testing Multiple Devices + +```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") +} +``` + +Run tests on multiple simulators: + +```bash +# iPhone 15 +xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15' + +# 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 + +```swift +func testDarkMode() { + app.launch() + + // Enable dark mode programmatically + app.buttons["Settings"].tap() + app.switches["Appearance"].tap() // Toggle to dark + + app.buttons["Done"].tap() + + // Capture dark mode screenshot + app.vizzlyScreenshot( + name: "home-dark", + properties: ["theme": "dark"] + ) +} +``` + +Or test both modes in one test: + +```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() + + // Wait for animation to complete + sleep(1) // Or use expectations + + // Use threshold for slight variations + app.vizzlyScreenshot( + name: "animated-banner", + threshold: 5 // Allow 5% difference + ) +} +``` + +## CI/CD Integration + +### GitHub Actions + +Create `.github/workflows/visual-tests.yml`: + +```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: npm 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" \ + --wait +``` + +### Fastlane + +Add to your `Fastfile`: + +```ruby +lane :visual_tests do + # Start Vizzly in cloud mode + sh("vizzly run 'bundle exec fastlane run_ui_tests' --wait") +end + +lane :run_ui_tests do + 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 + ] + ) + } +} +``` + +## 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 slightly higher threshold (1-2%) for font rendering differences + +### "Connection Refused" Errors + +**Cause**: TDD server not running or wrong port. + +**Solution**: + +```bash +# Check what's running on port 47392 +lsof -i :47392 + +# Kill any existing process +kill -9 + +# Restart server +vizzly tdd start +``` + +### Screenshots in Wrong Directory + +**Cause**: Tests running from different working directory. + +**Solution**: Ensure you run `vizzly tdd start` from your project root, or set `VIZZLY_SERVER_URL` explicitly: + +```bash +export VIZZLY_SERVER_URL=http://localhost:47392 +``` + +## Best Practices + +1. **Separate Visual Tests**: Keep visual regression tests in dedicated test files +2. **Descriptive Names**: Use hierarchical names like `checkout/payment/valid-card` +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 + +## Next Steps + +- 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 diff --git a/clients/swift/LICENSE b/clients/swift/LICENSE new file mode 100644 index 0000000..50d8964 --- /dev/null +++ b/clients/swift/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Vizzly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/clients/swift/Package.swift b/clients/swift/Package.swift new file mode 100644 index 0000000..1d11521 --- /dev/null +++ b/clients/swift/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Vizzly", + platforms: [ + .iOS(.v13), + .macOS(.v10_15) + ], + products: [ + .library( + name: "Vizzly", + targets: ["Vizzly"]), + ], + targets: [ + .target( + name: "Vizzly", + dependencies: []), + .testTarget( + name: "VizzlyTests", + dependencies: ["Vizzly"]), + ] +) diff --git a/clients/swift/QUICKSTART.md b/clients/swift/QUICKSTART.md new file mode 100644 index 0000000..d398e20 --- /dev/null +++ b/clients/swift/QUICKSTART.md @@ -0,0 +1,110 @@ +# Vizzly Swift SDK - Quick Start + +Get visual regression testing in your iOS app in 5 minutes. + +## 1. Install Vizzly CLI + +```bash +npm install -g @vizzly-testing/cli +``` + +## 2. Add Swift SDK to Xcode + +1. Open your iOS project in Xcode +2. **File → Add Package Dependencies** +3. Paste: `https://github.com/vizzly-testing/cli` +4. Add to your **UI Test target** (not main app) + +## 3. Start TDD Server + +In your iOS project directory: + +```bash +vizzly tdd start +``` + +## 4. Write a Visual Test + +```swift +import XCTest +import Vizzly + +class MyAppUITests: XCTestCase { + let app = XCUIApplication() + + func testHomeScreen() { + app.launch() + + // Wait for screen to load + let title = app.navigationBars["Home"] + XCTAssertTrue(title.waitForExistence(timeout: 5)) + + // 📸 Capture screenshot + app.vizzlyScreenshot(name: "home-screen") + } +} +``` + +## 5. Run Tests + +Press `Cmd+U` in Xcode, or: + +```bash +xcodebuild test \ + -scheme MyApp \ + -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +## 6. Review Results + +Open dashboard: **http://localhost:47392/dashboard** + +- ✅ 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. + +## 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 5% pixel difference (useful for animations) +app.vizzlyScreenshot( + name: "animated-view", + threshold: 5 +) +``` + +## Questions? + +- **Docs**: https://docs.vizzly.dev +- **GitHub**: https://github.com/vizzly-testing/cli +- **Support**: support@vizzly.dev diff --git a/clients/swift/README.md b/clients/swift/README.md new file mode 100644 index 0000000..c9efec2 --- /dev/null +++ b/clients/swift/README.md @@ -0,0 +1,458 @@ +# 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. + +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. + +## Features + +- **Zero Configuration** - Auto-discovers Vizzly TDD server +- **Native XCTest Integration** - Simple extensions for `XCUIApplication` and `XCUIElement` +- **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 + +## Installation + +### 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 to your UI test target + +Or add to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/vizzly-testing/cli", from: "1.0.0") +] +``` + +### CocoaPods + +```ruby +pod 'Vizzly', :git => 'https://github.com/vizzly-testing/cli', :branch => 'main' +``` + +## Quick Start + +### 1. Start Vizzly TDD Server + +```bash +cd /path/to/your/ios/project +vizzly tdd start +``` + +This starts a local server at `http://localhost:47392` that receives screenshots and performs visual comparisons. + +### 2. Add Vizzly to Your UI Tests + +```swift +import XCTest +import Vizzly + +class MyUITests: XCTestCase { + let app = XCUIApplication() + + func testHomeScreen() { + app.launch() + + // Capture screenshot - that's it! + app.vizzlyScreenshot(name: "home-screen") + } +} +``` + +### 3. Run Your Tests + +```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 at **http://localhost:47392/dashboard** to see visual comparisons, accept/reject changes, and review differences. + +## 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"] + ) +} +``` + +### Custom Threshold + +```swift +func testAnimatedContent() { + // Allow up to 5% pixel difference (useful for animations) + app.vizzlyScreenshot( + name: "animated-banner", + threshold: 5 + ) +} +``` + +### Multiple Device Orientations + +```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"] + ) +} +``` + +### Using the Client Directly + +```swift +import Vizzly + +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: Int = 0, + fullPage: Bool = false + ) -> [String: Any]? +} +``` + +### XCUIElement Extensions + +```swift +extension XCUIElement { + func vizzlyScreenshot( + name: String, + properties: [String: Any]? = nil, + threshold: Int = 0 + ) -> [String: Any]? +} +``` + +### XCTestCase Extensions + +```swift +extension XCTestCase { + func vizzlyScreenshot( + name: String, + app: XCUIApplication, + properties: [String: Any]? = nil, + threshold: Int = 0, + fullPage: Bool = false + ) -> [String: Any]? + + func vizzlyScreenshot( + name: String, + element: XCUIElement, + properties: [String: Any]? = nil, + threshold: Int = 0 + ) -> [String: Any]? +} +``` + +### VizzlyClient + +```swift +class VizzlyClient { + static let shared: VizzlyClient + + func screenshot( + name: String, + image: Data, + properties: [String: Any]? = nil, + threshold: Int = 0, + fullPage: Bool = false + ) -> [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 by searching for `.vizzly/server.json` in the current and parent directories. + +### Environment Variables + +- `VIZZLY_SERVER_URL` - Server URL (e.g., `http://localhost:47392`) +- `VIZZLY_BUILD_ID` - Build identifier for grouping screenshots (set automatically in CI) + +### Manual Configuration + +```swift +// Override auto-discovery +let client = VizzlyClient(serverUrl: "http://localhost:47392") +``` + +## 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: + +```swift +// ✅ Good +app.vizzlyScreenshot(name: "checkout/payment-form/valid-card") +app.vizzlyScreenshot(name: "settings/profile/edit-mode") + +// ❌ Avoid +app.vizzlyScreenshot(name: "screenshot1") +app.vizzlyScreenshot(name: "test") +``` + +### 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: Install Vizzly CLI + run: npm 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' \ + -resultBundlePath TestResults" \ + --wait +``` + +### Fastlane + +```ruby +lane :visual_tests do + scan( + scheme: "MyApp", + devices: ["iPhone 15"], + before_all: -> { + sh "vizzly run 'fastlane scan' --wait" + } + ) +end +``` + +## Troubleshooting + +### Screenshots Not Being Captured + +Check if Vizzly is ready: + +```swift +override func setUpWithError() throws { + if VizzlyClient.shared.isReady { + print("✓ Vizzly ready: \(VizzlyClient.shared.info)") + } else { + print("⚠️ Vizzly not available") + } +} +``` + +### Server Not Found + +1. Ensure TDD server is running: `vizzly tdd start` +2. Check `.vizzly/server.json` exists in your project +3. Verify the port in the server JSON matches the URL + +### 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 + +## Contributing + +Bug reports and pull requests are welcome at https://github.com/vizzly-testing/cli + +## License + +This SDK is available as open source under the terms of the MIT License. + +## Learn More + +- **Website**: https://vizzly.dev +- **Documentation**: https://docs.vizzly.dev +- **GitHub**: https://github.com/vizzly-testing/cli +- **Support**: support@vizzly.dev diff --git a/clients/swift/Sources/Vizzly/VizzlyClient.swift b/clients/swift/Sources/Vizzly/VizzlyClient.swift new file mode 100644 index 0000000..c372d7c --- /dev/null +++ b/clients/swift/Sources/Vizzly/VizzlyClient.swift @@ -0,0 +1,292 @@ +import Foundation + +/// Vizzly visual regression testing client for Swift/iOS +/// +/// A lightweight client SDK for capturing screenshots and sending them to Vizzly for visual +/// regression testing. Works with both local TDD mode and cloud builds. +/// +/// ## Usage +/// +/// ```swift +/// import Vizzly +/// +/// let client = VizzlyClient.shared +/// let screenshot = app.screenshot() +/// client.screenshot(name: "login-screen", image: screenshot.pngRepresentation) +/// ``` +public final class VizzlyClient { + + /// Shared singleton instance + public static let shared = VizzlyClient() + + /// Server URL for screenshot uploads + public private(set) var serverUrl: String? + + /// Whether the client is disabled (due to errors) + public private(set) var isDisabled = false + + private var hasWarned = false + private let defaultTddPort = 47392 + + /// Initialize a new Vizzly client + /// + /// - Parameter serverUrl: Optional server URL. If not provided, auto-discovery will be used. + public init(serverUrl: String? = nil) { + self.serverUrl = serverUrl ?? discoverServerUrl() + } + + /// Take a screenshot for visual regression testing + /// + /// - Parameters: + /// - name: Unique name for the screenshot + /// - image: PNG image data + /// - properties: Additional properties to attach (browser, viewport, etc.) + /// - threshold: Pixel difference threshold (0-100) + /// - fullPage: Whether this is a full page screenshot + /// - Returns: Response data if successful, nil otherwise + @discardableResult + public func screenshot( + name: String, + image: Data, + properties: [String: Any]? = nil, + threshold: Int = 0, + fullPage: Bool = false + ) -> [String: Any]? { + guard !isDisabled else { return nil } + + guard let serverUrl = serverUrl else { + warnOnce("Vizzly client not initialized. Screenshots will be skipped.") + disable() + return nil + } + + // Encode image to base64 + let imageBase64 = image.base64EncodedString() + + // Build payload + var payload: [String: Any] = [ + "name": name, + "image": imageBase64, + "threshold": threshold, + "fullPage": fullPage + ] + + if let buildId = ProcessInfo.processInfo.environment["VIZZLY_BUILD_ID"] { + payload["buildId"] = buildId + } + + if let properties = properties { + payload["properties"] = properties + } + + // Send HTTP request + guard let url = URL(string: "\(serverUrl)/screenshot") else { + print("❌ Invalid server URL: \(serverUrl)") + disable() + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + } catch { + print("❌ Failed to encode screenshot payload: \(error)") + return nil + } + + // Use semaphore for synchronous request (needed in test context) + let semaphore = DispatchSemaphore(value: 0) + var result: [String: Any]? + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + self.handleError(name: name, error: error) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + print("❌ Invalid response for screenshot: \(name)") + self.disable(reason: "failure") + return + } + + guard let data = data else { + print("❌ No data received for screenshot: \(name)") + self.disable(reason: "failure") + return + } + + // Handle non-success responses + if httpResponse.statusCode != 200 { + self.handleNonSuccessResponse( + statusCode: httpResponse.statusCode, + data: data, + name: name + ) + return + } + + // Parse successful response + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = json + } + } catch { + print("⚠️ Failed to parse response for \(name): \(error)") + } + } + + task.resume() + semaphore.wait() + + return result + } + + /// Flush any pending screenshots (no-op for simple client) + public func flush() { + // Simple client doesn't queue screenshots + } + + /// Check if the client is ready to capture screenshots + public var isReady: Bool { + return !isDisabled && serverUrl != nil + } + + /// Disable screenshot capture + /// + /// - Parameter reason: Optional reason for disabling + public func disable(reason: String = "disabled") { + isDisabled = true + + if reason != "disabled" { + print("⚠️ Vizzly SDK disabled due to \(reason). Screenshots will be skipped for the remainder of this session.") + } + } + + /// Get client information + public var info: [String: Any] { + var info: [String: Any] = [ + "enabled": !isDisabled, + "ready": isReady, + "disabled": isDisabled + ] + + if let serverUrl = serverUrl { + info["serverUrl"] = serverUrl + } + + if let buildId = ProcessInfo.processInfo.environment["VIZZLY_BUILD_ID"] { + info["buildId"] = buildId + } + + return info + } + + // MARK: - Private Methods + + private func warnOnce(_ message: String) { + guard !hasWarned else { return } + print("⚠️ \(message)") + hasWarned = true + } + + private func discoverServerUrl() -> String? { + // First check environment variable + if let envUrl = ProcessInfo.processInfo.environment["VIZZLY_SERVER_URL"] { + return envUrl + } + + // Then try auto-discovery + return autoDiscoverTddServer() + } + + private func autoDiscoverTddServer() -> String? { + let fileManager = FileManager.default + var currentDir = fileManager.currentDirectoryPath + + while currentDir != "/" { + let serverJsonPath = (currentDir as NSString).appendingPathComponent(".vizzly/server.json") + + if fileManager.fileExists(atPath: serverJsonPath) { + do { + let data = try Data(contentsOf: URL(fileURLWithPath: serverJsonPath)) + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let port = json["port"] as? Int { + return "http://localhost:\(port)" + } + } catch { + // Invalid JSON or file disappeared, continue searching + } + } + + // Move to parent directory + let parentDir = (currentDir as NSString).deletingLastPathComponent + if parentDir == currentDir { break } + currentDir = parentDir + } + + return nil + } + + private func handleError(name: String, error: Error) { + print("⚠️ Vizzly screenshot failed for \(name): \(error.localizedDescription)") + + let errorString = error.localizedDescription.lowercased() + + if errorString.contains("connection") || errorString.contains("could not connect") { + if let serverUrl = serverUrl { + print(" Server URL: \(serverUrl)/screenshot") + print(" This usually means the Vizzly server is not running or not accessible") + print(" Check that the server is started and the port is correct") + } + } + + disable(reason: "failure") + } + + private func handleNonSuccessResponse(statusCode: Int, data: Data, name: String) { + var errorData: [String: Any] = [:] + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + errorData = json + } + } catch { + // Ignore parse errors + } + + // In TDD mode with visual differences, log but don't disable + if statusCode == 422, + let tddMode = errorData["tddMode"] as? Bool, + tddMode, + let comparison = errorData["comparison"] as? [String: Any] { + + let diffPercent = (comparison["diffPercentage"] as? Double)?.rounded() ?? 0.0 + + // Extract port from serverUrl + var port = defaultTddPort + if let serverUrl = serverUrl, + let range = serverUrl.range(of: ":(\\d+)", options: .regularExpression), + let portString = serverUrl[range].split(separator: ":").last, + let parsedPort = Int(portString) { + port = parsedPort + } + + let dashboardUrl = "http://localhost:\(port)/dashboard" + + print("⚠️ Visual diff: \(comparison["name"] ?? name) (\(diffPercent)%) → \(dashboardUrl)") + return + } + + // Other errors - disable client + let errorMessage = errorData["error"] as? String ?? "Unknown error" + print("❌ Screenshot failed: \(statusCode) - \(errorMessage)") + disable(reason: "failure") + } +} diff --git a/clients/swift/Sources/Vizzly/XCTestExtensions.swift b/clients/swift/Sources/Vizzly/XCTestExtensions.swift new file mode 100644 index 0000000..4af6098 --- /dev/null +++ b/clients/swift/Sources/Vizzly/XCTestExtensions.swift @@ -0,0 +1,188 @@ +import Foundation +import XCTest + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// XCTest extensions for easy Vizzly integration +extension XCTestCase { + + /// Capture a screenshot and send it to Vizzly + /// + /// - Parameters: + /// - name: Unique name for the screenshot + /// - app: The XCUIApplication instance (iOS/macOS) + /// - properties: Additional properties to attach + /// - threshold: Pixel difference threshold (0-100) + /// - fullPage: Whether this is a full page screenshot + @available(iOS 13.0, macOS 10.15, *) + @discardableResult + public func vizzlyScreenshot( + name: String, + app: XCUIApplication, + properties: [String: Any]? = nil, + threshold: Int = 0, + fullPage: Bool = false + ) -> [String: Any]? { + let screenshot = app.screenshot() + + var combinedProperties = properties ?? [:] + + // Add device/platform info automatically + #if os(iOS) + combinedProperties["platform"] = "iOS" + combinedProperties["device"] = UIDevice.current.model + combinedProperties["osVersion"] = UIDevice.current.systemVersion + + // Add screen size + let screen = UIScreen.main + combinedProperties["viewport"] = [ + "width": Int(screen.bounds.width * screen.scale), + "height": Int(screen.bounds.height * screen.scale), + "scale": screen.scale + ] + #elseif os(macOS) + combinedProperties["platform"] = "macOS" + + if let screen = NSScreen.main { + combinedProperties["viewport"] = [ + "width": Int(screen.frame.width), + "height": Int(screen.frame.height) + ] + } + #endif + + return VizzlyClient.shared.screenshot( + name: name, + image: screenshot.pngRepresentation, + properties: combinedProperties, + threshold: threshold, + fullPage: fullPage + ) + } + + /// Capture a screenshot of a specific element and send it to Vizzly + /// + /// - Parameters: + /// - name: Unique name for the screenshot + /// - element: The XCUIElement to screenshot + /// - properties: Additional properties to attach + /// - threshold: Pixel difference threshold (0-100) + @available(iOS 13.0, macOS 10.15, *) + @discardableResult + public func vizzlyScreenshot( + name: String, + element: XCUIElement, + properties: [String: Any]? = nil, + threshold: Int = 0 + ) -> [String: Any]? { + let screenshot = element.screenshot() + + var combinedProperties = properties ?? [:] + + // Add element info + combinedProperties["elementType"] = element.elementType.rawValue + + #if os(iOS) + combinedProperties["platform"] = "iOS" + #elseif os(macOS) + combinedProperties["platform"] = "macOS" + #endif + + return VizzlyClient.shared.screenshot( + name: name, + image: screenshot.pngRepresentation, + properties: combinedProperties, + threshold: threshold, + fullPage: false + ) + } +} + +/// Convenience extensions for XCUIApplication +@available(iOS 13.0, macOS 10.15, *) +extension XCUIApplication { + + /// Capture a screenshot and send it to Vizzly + /// + /// - Parameters: + /// - name: Unique name for the screenshot + /// - properties: Additional properties to attach + /// - threshold: Pixel difference threshold (0-100) + /// - fullPage: Whether this is a full page screenshot + @discardableResult + public func vizzlyScreenshot( + name: String, + properties: [String: Any]? = nil, + threshold: Int = 0, + fullPage: Bool = false + ) -> [String: Any]? { + let screenshot = self.screenshot() + + var combinedProperties = properties ?? [:] + + #if os(iOS) + combinedProperties["platform"] = "iOS" + combinedProperties["device"] = UIDevice.current.model + + let screen = UIScreen.main + combinedProperties["viewport"] = [ + "width": Int(screen.bounds.width * screen.scale), + "height": Int(screen.bounds.height * screen.scale), + "scale": screen.scale + ] + #elseif os(macOS) + combinedProperties["platform"] = "macOS" + #endif + + return VizzlyClient.shared.screenshot( + name: name, + image: screenshot.pngRepresentation, + properties: combinedProperties, + threshold: threshold, + fullPage: fullPage + ) + } +} + +/// Convenience extensions for XCUIElement +@available(iOS 13.0, macOS 10.15, *) +extension XCUIElement { + + /// Capture a screenshot of this element and send it to Vizzly + /// + /// - Parameters: + /// - name: Unique name for the screenshot + /// - properties: Additional properties to attach + /// - threshold: Pixel difference threshold (0-100) + @discardableResult + public func vizzlyScreenshot( + name: String, + properties: [String: Any]? = nil, + threshold: Int = 0 + ) -> [String: Any]? { + let screenshot = self.screenshot() + + var combinedProperties = properties ?? [:] + combinedProperties["elementType"] = self.elementType.rawValue + + #if os(iOS) + combinedProperties["platform"] = "iOS" + #elseif os(macOS) + combinedProperties["platform"] = "macOS" + #endif + + return VizzlyClient.shared.screenshot( + name: name, + image: screenshot.pngRepresentation, + properties: combinedProperties, + threshold: threshold, + fullPage: false + ) + } +} diff --git a/clients/swift/Tests/VizzlyTests/VizzlyClientTests.swift b/clients/swift/Tests/VizzlyTests/VizzlyClientTests.swift new file mode 100644 index 0000000..6168894 --- /dev/null +++ b/clients/swift/Tests/VizzlyTests/VizzlyClientTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import Vizzly + +final class VizzlyClientTests: XCTestCase { + + func testClientInitialization() { + let client = VizzlyClient() + XCTAssertNotNil(client) + } + + func testClientWithCustomUrl() { + let customUrl = "http://localhost:9999" + let client = VizzlyClient(serverUrl: customUrl) + XCTAssertEqual(client.serverUrl, customUrl) + } + + func testClientInfo() { + let client = VizzlyClient() + let info = client.info + + XCTAssertNotNil(info["enabled"]) + XCTAssertNotNil(info["ready"]) + XCTAssertNotNil(info["disabled"]) + + XCTAssertTrue(info["enabled"] as? Bool ?? false) + XCTAssertFalse(info["disabled"] as? Bool ?? true) + } + + func testClientDisable() { + let client = VizzlyClient() + + XCTAssertFalse(client.isDisabled) + XCTAssertTrue(client.isReady || client.serverUrl == nil) + + client.disable() + + XCTAssertTrue(client.isDisabled) + XCTAssertFalse(client.isReady) + } + + func testFlush() { + let client = VizzlyClient() + // Flush should not crash + client.flush() + } + + func testScreenshotWithDisabledClient() { + let client = VizzlyClient() + client.disable() + + let testImage = createTestImage() + let result = client.screenshot(name: "test", image: testImage) + + XCTAssertNil(result) + } + + func testScreenshotWithNoServer() { + // Create client with invalid server URL + let client = VizzlyClient(serverUrl: nil) + + let testImage = createTestImage() + let result = client.screenshot(name: "test", image: testImage) + + XCTAssertNil(result) + XCTAssertTrue(client.isDisabled) + } + + // MARK: - Helpers + + private func createTestImage() -> Data { + // Create a simple 1x1 pixel PNG + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return Data() + } + + context.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + + guard let cgImage = context.makeImage() else { + return Data() + } + + #if os(iOS) + let image = UIImage(cgImage: cgImage) + return image.pngData() ?? Data() + #elseif os(macOS) + let image = NSImage(cgImage: cgImage, size: NSSize(width: 1, height: 1)) + guard let tiffData = image.tiffRepresentation, + let bitmapImage = NSBitmapImageRep(data: tiffData), + let pngData = bitmapImage.representation(using: .png, properties: [:]) else { + return Data() + } + return pngData + #else + return Data() + #endif + } +}