From b3b668a2ecdb834bd244fea164f3f722e9f831e5 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sun, 30 Aug 2026 01:38:28 -0500 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=A7=AA=20Spike=20native=20SwiftUI?= =?UTF-8?q?=20preview=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render existing stock #Preview declarations from the built app target through a Simulator-injected Swift runtime. Add the Vizzly CLI plugin, two-preview fixture, manifest output, and repeatable end-to-end coverage. --- clients/swift/CHANGELOG.md | 7 + .../Assets.xcassets/Contents.json | 6 + .../PreviewAccent.colorset/Contents.json | 20 + .../PreviewFixture/PreviewFixtureApp.swift | 61 +++ clients/swift/PREVIEWS-SPIKE.md | 31 ++ clients/swift/Package.swift | 11 + .../RuntimeConstructor.c | 24 + .../include/CVizzlyPreviewRuntime.h | 6 + .../VizzlyPreviewRuntime.swift | 261 ++++++++++ clients/swift/package.json | 37 ++ clients/swift/scripts/run-preview-e2e.js | 51 ++ clients/swift/src/index.js | 45 ++ clients/swift/src/plugin.js | 30 ++ clients/swift/src/preview-runner.js | 471 ++++++++++++++++++ clients/swift/tests-js/preview-runner.test.js | 52 ++ package.json | 1 + pnpm-lock.yaml | 12 + 17 files changed, 1126 insertions(+) create mode 100644 clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/Contents.json create mode 100644 clients/swift/Fixtures/PreviewFixture/PreviewFixture/Assets.xcassets/PreviewAccent.colorset/Contents.json create mode 100644 clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift create mode 100644 clients/swift/PREVIEWS-SPIKE.md create mode 100644 clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c create mode 100644 clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h create mode 100644 clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift create mode 100644 clients/swift/package.json create mode 100644 clients/swift/scripts/run-preview-e2e.js create mode 100644 clients/swift/src/index.js create mode 100644 clients/swift/src/plugin.js create mode 100644 clients/swift/src/preview-runner.js create mode 100644 clients/swift/tests-js/preview-runner.test.js diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index f9cde6ad..99147733 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added an experimental `vizzly previews` plugin and native Simulator runtime + that render existing stock SwiftUI `#Preview` declarations without Xcode MCP. +- Added a two-preview iOS fixture that exercises app-module discovery, a named + asset, runtime injection, PNG capture, and manifest generation. + ## [0.1.0] - 2026-06-01 ### What's Changed 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..f54b4c74 --- /dev/null +++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift @@ -0,0 +1,61 @@ +import SwiftUI + +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 { + var body: some Scene { + WindowGroup { + Text("Ordinary app root") + } + } +} + +#Preview("Card / Dark") { + PreviewCard(title: "Stock #Preview") + .preferredColorScheme(.dark) +} + +#Preview("Stateful Counter") { + StatefulCounter() +} diff --git a/clients/swift/PREVIEWS-SPIKE.md b/clients/swift/PREVIEWS-SPIKE.md new file mode 100644 index 00000000..861260ff --- /dev/null +++ b/clients/swift/PREVIEWS-SPIKE.md @@ -0,0 +1,31 @@ +# Stock `#Preview` capture spike + +This spike adds a `vizzly previews` CLI extension without inventing a second +preview declaration API. It builds the selected Debug app for an already +booted iOS Simulator, finds the generated `DeveloperToolsSupport.PreviewRegistry` +types in the built Mach-O, and launches the real app once per registry. + +A small Swift dylib is injected only into those capture launches. It intercepts +the stock `DeveloperToolsSupport.Preview` initializer, keeps the original +`@MainActor () -> any View` closure, mounts that view in the app window, and +writes a PNG. The CLI copies each PNG to the requested host directory and writes +`manifest.json`. + +```sh +vizzly previews MyApp.xcodeproj \ + --scheme MyApp \ + --device B40B976E-CD70-45F2-830C-48E8ED9B7EE7 \ + --output .vizzly/previews +``` + +The current cutline is deliberately narrow: + +- Xcode 26.6 and Swift 6.3.3 +- Debug iOS apps on an arm64 iOS Simulator +- SwiftUI `#Preview` declarations +- one fresh app process per preview +- local PNG and manifest output; Vizzly upload is the next integration step + +The implementation fails closed on another Xcode version because the +interceptor uses a Swift ABI symbol. It does not use Xcode MCP, `mcpbridge`, +Xcode's private preview action, source rewriting, or a `#VizzlyPreview` macro. diff --git a/clients/swift/Package.swift b/clients/swift/Package.swift index 5ab62e32..9621908a 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: .static, + 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/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c b/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c new file mode 100644 index 00000000..f9fe827d --- /dev/null +++ b/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c @@ -0,0 +1,24 @@ +#include "CVizzlyPreviewRuntime.h" + +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; +} + +__attribute__((constructor)) +static void start_vizzly_preview_runtime(void) { + VizzlyPreviewRuntimeStart(); +} diff --git a/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h b/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h new file mode 100644 index 00000000..e6f45fea --- /dev/null +++ b/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h @@ -0,0 +1,6 @@ +#ifndef CVIZZLY_PREVIEW_RUNTIME_H +#define CVIZZLY_PREVIEW_RUNTIME_H + +void VizzlyPreviewRuntimeStart(void); + +#endif diff --git a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift new file mode 100644 index 00000000..a160c61a --- /dev/null +++ b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift @@ -0,0 +1,261 @@ +#if os(iOS) && targetEnvironment(simulator) +import Darwin +import DeveloperToolsSupport +import Foundation +import SwiftUI +import UIKit + +public enum VizzlyPreviewRuntime { + public static func link() {} +} + +public typealias VizzlyPreviewBody = @MainActor () -> any View +public typealias VizzlyPreviewInitializer = @convention(thin) @MainActor ( + String?, + [PreviewTrait], + @escaping VizzlyPreviewBody +) -> Preview + +@_silgen_name("VizzlyOriginalPreviewInitializer") +private func originalPreviewInitializerPointer() -> UnsafeRawPointer + +@MainActor +private var capturedPreviewBody: VizzlyPreviewBody? + +@MainActor +private var capturedPreviewName = "Unnamed Preview" + +private var activationObserver: NSObjectProtocol? + +@_silgen_name("VizzlyPreviewInitializerReplacement") +@MainActor +public func interceptPreviewInitializer( + _ name: String?, + traits: [PreviewTrait], + body: @escaping VizzlyPreviewBody +) -> Preview { + capturedPreviewBody = body + capturedPreviewName = name ?? "Unnamed Preview" + + let original = unsafeBitCast( + originalPreviewInitializerPointer(), + to: VizzlyPreviewInitializer.self + ) + return original(name, traits, body) +} + +@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) +} + +@MainActor +private func resolvePreview() throws -> AnyView { + 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 view = body() + emitEvent([ + "protocolVersion": 1, + "type": "preview-resolved", + "name": capturedPreviewName, + "registryType": registryName, + "viewType": String(reflecting: type(of: view)), + ]) + return AnyView(view) +} + +private struct InjectedPreviewRoot: View { + let preview: AnyView + + var body: some View { + preview.background { + CaptureProbe().frame(width: 0, height: 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 { + awaitOneRenderPass() + + guard let window = view.window else { + throw PreviewRuntimeError.windowUnavailable + } + + window.layoutIfNeeded() + let format = UIGraphicsImageRendererFormat() + format.scale = window.screen.scale + format.opaque = true + let renderer = UIGraphicsImageRenderer( + bounds: window.bounds, + format: format + ) + let image = renderer.image { _ in + window.drawHierarchy( + in: window.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 awaitOneRenderPass() { + CATransaction.flush() + } + } +} + +@MainActor +private func installPreview() { + do { + guard + let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first, + let window = scene.windows.first + else { + throw PreviewRuntimeError.windowUnavailable + } + + let preview = try resolvePreview() + window.rootViewController = UIHostingController( + rootView: InjectedPreviewRoot(preview: preview) + ) + window.makeKeyAndVisible() + } catch { + emitFailure(error) + exit(EXIT_FAILURE) + } +} + +@MainActor +private func emitFailure(_ error: Error) { + emitEvent([ + "protocolVersion": 1, + "type": "capture-failed", + "message": error.localizedDescription, + ]) +} + +@_cdecl("VizzlyPreviewRuntimeStart") +public func startVizzlyPreviewRuntime() { + guard + ProcessInfo.processInfo.environment["VIZZLY_REGISTRY_TYPE"] != nil + else { + return + } + + activationObserver = NotificationCenter.default.addObserver( + forName: UIScene.didActivateNotification, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { + installPreview() + } + } +} + +private enum PreviewRuntimeError: LocalizedError { + case bodyUnavailable + case pngEncodingFailed + case registryUnavailable + 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 .windowUnavailable: + return "The app did not create a window for preview capture" + } + } +} +#else +public enum VizzlyPreviewRuntime { + public static func link() {} +} + +@_cdecl("VizzlyPreviewRuntimeStart") +public func startVizzlyPreviewRuntime() {} +#endif diff --git a/clients/swift/package.json b/clients/swift/package.json new file mode 100644 index 00000000..46f037f3 --- /dev/null +++ b/clients/swift/package.json @@ -0,0 +1,37 @@ +{ + "name": "@vizzly-testing/swift", + "version": "0.1.0", + "description": "Native Swift and SwiftUI preview integration for Vizzly", + "type": "module", + "exports": { + ".": "./src/index.js", + "./plugin": "./src/plugin.js" + }, + "vizzlyPlugin": "./src/plugin.js", + "files": [ + "src", + "Sources/VizzlyPreviewRuntime", + "Sources/CVizzlyPreviewRuntime", + "Package.swift", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "scripts": { + "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", + "format": "biome format --write src tests-js" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@vizzly-testing/cli": ">=0.35.0-0" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.10", + "@vizzly-testing/cli": "workspace:*", + "commander": "^15.0.0" + } +} diff --git a/clients/swift/scripts/run-preview-e2e.js b/clients/swift/scripts/run-preview-e2e.js new file mode 100644 index 00000000..86688c6a --- /dev/null +++ b/clients/swift/scripts/run-preview-e2e.js @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } 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; +if (!device) { + throw new Error( + 'Set VIZZLY_SIMULATOR_UDID to an already-booted iOS Simulator UDID' + ); +} + +let outputPath = await mkdtemp(join(tmpdir(), 'vizzly-preview-e2e-')); + +try { + let manifest = await runPreviewCapture({ + container: resolve( + import.meta.dirname, + '..', + 'Fixtures', + 'PreviewFixture', + 'PreviewFixture.xcodeproj' + ), + scheme: 'PreviewFixture', + device, + configuration: 'Debug', + outputPath, + onProgress: message => process.stdout.write(`${message}\n`), + }); + + assert.deepEqual(manifest.previews.map(preview => preview.name).sort(), [ + 'Card / Dark', + '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); + + process.stdout.write( + `Verified ${manifest.previews.length} stock #Preview screenshots\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..a1fbc403 --- /dev/null +++ b/clients/swift/src/index.js @@ -0,0 +1,45 @@ +import { runPreviewCapture } from './preview-runner.js'; + +export async function run(container, options = {}, context = {}) { + let config = context.config?.swiftPreviews ?? {}; + let scheme = options.scheme ?? config.scheme; + let device = options.device ?? config.device; + let outputPath = options.output ?? config.output ?? '.vizzly/previews'; + + if (!scheme) { + throw new Error('Swift preview capture requires --scheme '); + } + + if (!device) { + throw new Error('Swift preview capture requires --device '); + } + + let output = context.output ?? { + info: message => process.stdout.write(`${message}\n`), + }; + + output.info( + 'Building the iOS app and discovering stock #Preview declarations' + ); + let manifest = await runPreviewCapture({ + container, + scheme, + device, + configuration: options.configuration ?? config.configuration ?? 'Debug', + outputPath, + onProgress: message => output.info(message), + }); + + if (options.json) { + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + } else { + output.info( + `Captured ${manifest.previews.length} SwiftUI previews in ${manifest.outputPath}` + ); + } + + return manifest; +} + +export { runPreviewCapture } from './preview-runner.js'; +export { run as default }; diff --git a/clients/swift/src/plugin.js b/clients/swift/src/plugin.js new file mode 100644 index 00000000..45c5af6c --- /dev/null +++ b/clients/swift/src/plugin.js @@ -0,0 +1,30 @@ +import packageJson from '../package.json' with { type: 'json' }; +import { run } from './index.js'; + +export default { + name: 'swift-previews', + version: packageJson.version, + configSchema: { + swiftPreviews: { + configuration: 'Debug', + output: '.vizzly/previews', + }, + }, + + register(program, context) { + program + .command('previews [container]') + .description( + 'Render screenshots from stock SwiftUI #Preview declarations' + ) + .option('--scheme ', 'Xcode scheme to build') + .option('--device ', 'Booted iOS Simulator UDID') + .option('--configuration ', 'Build configuration', 'Debug') + .option('--output ', 'Screenshot output directory') + .option('--json', 'Print the capture manifest as JSON') + .action(async (container = '.', options) => { + let globalOptions = program.opts(); + await run(container, { ...globalOptions, ...options }, context); + }); + }, +}; diff --git a/clients/swift/src/preview-runner.js b/clients/swift/src/preview-runner.js new file mode 100644 index 00000000..ff387606 --- /dev/null +++ b/clients/swift/src/preview-runner.js @@ -0,0 +1,471 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + access, + copyFile, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, extname, join, resolve } from 'node:path'; + +let eventPrefix = 'VIZZLY_PREVIEW_EVENT '; +let supportedXcodeVersion = '26.6'; + +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 < 24 || !buffer.subarray(0, 8).equals(signature)) { + throw new Error('Preview capture did not produce a valid PNG'); + } + + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + sha256: createHash('sha256').update(buffer).digest('hex'), + }; +} + +function runCommand(executable, args, options = {}) { + return new Promise((resolvePromise, rejectPromise) => { + let child = spawn(executable, args, { + cwd: options.cwd, + env: options.env ?? process.env, + 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', rejectPromise); + child.once('close', (exitCode, signal) => { + let result = { + exitCode, + signal, + 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 assertSupportedToolchain() { + let result = await runCommand('xcodebuild', ['-version']); + let match = result.stdout.match(/^Xcode (\S+)/m); + if (!match || match[1] !== supportedXcodeVersion) { + throw new Error( + `Unsupported preview ABI. This spike requires Xcode ${supportedXcodeVersion}` + ); + } + return match[1]; +} + +async function ensureEmptyOutput(outputPath) { + if (!(await pathExists(outputPath))) { + await mkdir(outputPath, { recursive: true }); + return; + } + + let entries = await readdir(outputPath); + if (entries.length > 0) { + throw new Error(`Preview output directory must be empty: ${outputPath}`); + } +} + +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 }; +} + +async function applicationBinaries(appPath, settings) { + let candidates = [ + join(appPath, settings.EXECUTABLE_PATH ?? settings.EXECUTABLE_NAME), + join(appPath, `${settings.PRODUCT_NAME}.debug.dylib`), + ]; + 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 (candidate && (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(); +} + +async function compileRuntime(clientRoot, buildPath, deploymentTarget) { + let sdkResult = await runCommand('xcrun', [ + '--sdk', + 'iphonesimulator', + '--show-sdk-path', + ]); + let sdkPath = sdkResult.stdout.trim(); + let moduleCache = join(buildPath, 'module-cache'); + let objectPath = join(buildPath, 'RuntimeConstructor.o'); + let dylibPath = join(buildPath, 'libVizzlyPreviewRuntime.dylib'); + let cSource = join( + clientRoot, + 'Sources', + 'CVizzlyPreviewRuntime', + 'RuntimeConstructor.c' + ); + let headerPath = join( + clientRoot, + 'Sources', + 'CVizzlyPreviewRuntime', + 'include' + ); + let swiftSource = join( + clientRoot, + 'Sources', + 'VizzlyPreviewRuntime', + 'VizzlyPreviewRuntime.swift' + ); + let target = `arm64-apple-ios${deploymentTarget}-simulator`; + + await mkdir(moduleCache, { recursive: true }); + await runCommand('xcrun', [ + '--sdk', + 'iphonesimulator', + 'clang', + '-c', + cSource, + '-I', + headerPath, + '-target', + target, + '-isysroot', + sdkPath, + '-o', + objectPath, + ]); + await runCommand('xcrun', [ + '--toolchain', + 'XcodeDefault', + 'swiftc', + '-emit-library', + swiftSource, + objectPath, + '-module-name', + 'VizzlyPreviewRuntime', + '-target', + target, + '-sdk', + sdkPath, + '-parse-as-library', + '-module-cache-path', + moduleCache, + '-o', + dylibPath, + ]); + return dylibPath; +} + +async function embedRuntime(appPath, dylibPath) { + let frameworksPath = join(appPath, 'Frameworks'); + let embeddedPath = join(frameworksPath, basename(dylibPath)); + await mkdir(frameworksPath, { recursive: true }); + await copyFile(dylibPath, embeddedPath); + await runCommand('codesign', ['--force', '--sign', '-', embeddedPath]); + await runCommand('codesign', ['--force', '--deep', '--sign', '-', appPath]); +} + +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, +}) { + let runtimeFilename = 'vizzly-preview.png'; + let runtimePath = join(containerPath, 'Documents', runtimeFilename); + await rm(runtimePath, { force: true }); + + let result = await runCommand( + 'xcrun', + [ + 'simctl', + 'launch', + '--console', + '--terminate-running-process', + device, + bundleId, + ], + { + allowFailure: true, + env: { + ...process.env, + SIMCTL_CHILD_DYLD_INSERT_LIBRARIES: + '@executable_path/Frameworks/libVizzlyPreviewRuntime.dylib', + SIMCTL_CHILD_VIZZLY_REGISTRY_TYPE: registryType, + SIMCTL_CHILD_VIZZLY_OUTPUT_FILENAME: runtimeFilename, + }, + } + ); + 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 ?? 'The app exited without capture completion'; + throw new Error(`Preview ${index + 1} failed: ${reason}`); + } + + 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 { + 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, + onProgress = () => {}, +}) { + let clientRoot = resolve(import.meta.dirname, '..'); + let container = await resolveContainer(containerInput); + let outputPath = resolve(outputInput); + await ensureEmptyOutput(outputPath); + let temporaryPath = await mkdtemp(join(tmpdir(), 'vizzly-previews-')); + + try { + let xcodeVersion = await assertSupportedToolchain(); + let derivedDataPath = join(temporaryPath, 'DerivedData'); + let build = await buildApplication({ + container, + scheme, + device, + configuration, + derivedDataPath, + }); + let registries = await discoverRegistries(build.appPath, build.settings); + if (registries.length === 0) { + throw new Error(`No stock #Preview declarations were found in ${scheme}`); + } + onProgress(`Discovered ${registries.length} stock #Preview declarations`); + + let runtimePath = await compileRuntime( + clientRoot, + temporaryPath, + build.settings.IPHONEOS_DEPLOYMENT_TARGET ?? '18.0' + ); + await embedRuntime(build.appPath, runtimePath); + await runCommand('xcrun', ['simctl', 'install', device, build.appPath]); + + let bundleId = build.settings.PRODUCT_BUNDLE_IDENTIFIER; + let containerResult = await runCommand('xcrun', [ + 'simctl', + 'get_app_container', + device, + bundleId, + 'data', + ]); + let containerPath = containerResult.stdout.trim(); + let previews = []; + for (let [index, registryType] of registries.entries()) { + let preview = await captureRegistry({ + registryType, + index, + device, + bundleId, + containerPath, + outputPath, + }); + previews.push(preview); + onProgress(`Captured ${preview.name}`); + } + + let manifest = { + protocolVersion: 1, + xcodeVersion, + container, + scheme, + device, + configuration, + outputPath, + previews, + }; + await writeFile( + join(outputPath, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + return manifest; + } catch (error) { + error.message = `Swift preview capture failed: ${error.message}`; + throw error; + } finally { + await rm(temporaryPath, { recursive: true, force: 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..46ce2ce7 --- /dev/null +++ b/clients/swift/tests-js/preview-runner.test.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + parseRegistryTypes, + parseRuntimeEvents, + readPngMetadata, +} from '../src/preview-runner.js'; + +describe('Swift preview runner contracts', () => { + 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('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(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png); + png.writeUInt32BE(393, 16); + png.writeUInt32BE(852, 20); + + 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/); + }); +}); diff --git a/package.json b/package.json index afd59def..67524988 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e6d1ad9..9d4d9c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,18 @@ importers: specifier: ^6.0.1 version: 6.1.3 + clients/swift: + devDependencies: + '@biomejs/biome': + specifier: ^2.5.10 + version: 2.5.10 + '@vizzly-testing/cli': + specifier: workspace:* + version: link:../.. + commander: + specifier: ^15.0.0 + version: 15.0.0 + clients/storybook: dependencies: '@vizzly-testing/cli': From f9789d4cfcbe162d1f987c9b342fa3a382d9aec1 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sun, 30 Aug 2026 01:42:14 -0500 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20Auto-detect=20the=20booted=20?= =?UTF-8?q?iOS=20Simulator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select the device automatically when exactly one available iOS Simulator is booted. Keep ambiguous and stale-device cases explicit, actionable, and covered by the real preview capture path. --- clients/swift/CHANGELOG.md | 2 + clients/swift/PREVIEWS-SPIKE.md | 7 +- clients/swift/scripts/run-preview-e2e.js | 6 - clients/swift/src/index.js | 6 +- clients/swift/src/plugin.js | 5 +- clients/swift/src/preview-runner.js | 107 +++++++++++++++++- clients/swift/tests-js/preview-runner.test.js | 101 +++++++++++++++++ 7 files changed, 216 insertions(+), 18 deletions(-) diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 99147733..3d720479 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that render existing stock SwiftUI `#Preview` declarations without Xcode MCP. - Added a two-preview iOS fixture that exercises app-module discovery, a named asset, runtime injection, PNG capture, and manifest generation. +- Added conservative booted iOS Simulator detection, with an explicit choice + required when more than one Simulator is booted. ## [0.1.0] - 2026-06-01 diff --git a/clients/swift/PREVIEWS-SPIKE.md b/clients/swift/PREVIEWS-SPIKE.md index 861260ff..3cecfb35 100644 --- a/clients/swift/PREVIEWS-SPIKE.md +++ b/clients/swift/PREVIEWS-SPIKE.md @@ -14,10 +14,15 @@ writes a PNG. The CLI copies each PNG to the requested host directory and writes ```sh vizzly previews MyApp.xcodeproj \ --scheme MyApp \ - --device B40B976E-CD70-45F2-830C-48E8ED9B7EE7 \ --output .vizzly/previews ``` +When exactly one available iOS Simulator is booted, the CLI selects it. If +more than one is booted, it lists the choices and asks for `--device ` +instead of guessing. Passing `--device` still validates that the selected iOS +Simulator is booted. `VIZZLY_SIMULATOR_UDID` remains useful for the E2E script +when a machine intentionally has multiple booted Simulators. + The current cutline is deliberately narrow: - Xcode 26.6 and Swift 6.3.3 diff --git a/clients/swift/scripts/run-preview-e2e.js b/clients/swift/scripts/run-preview-e2e.js index 86688c6a..e87e0b9f 100644 --- a/clients/swift/scripts/run-preview-e2e.js +++ b/clients/swift/scripts/run-preview-e2e.js @@ -5,12 +5,6 @@ import { join, resolve } from 'node:path'; import { runPreviewCapture } from '../src/preview-runner.js'; let device = process.env.VIZZLY_SIMULATOR_UDID; -if (!device) { - throw new Error( - 'Set VIZZLY_SIMULATOR_UDID to an already-booted iOS Simulator UDID' - ); -} - let outputPath = await mkdtemp(join(tmpdir(), 'vizzly-preview-e2e-')); try { diff --git a/clients/swift/src/index.js b/clients/swift/src/index.js index a1fbc403..ef7f6d4f 100644 --- a/clients/swift/src/index.js +++ b/clients/swift/src/index.js @@ -10,16 +10,12 @@ export async function run(container, options = {}, context = {}) { throw new Error('Swift preview capture requires --scheme '); } - if (!device) { - throw new Error('Swift preview capture requires --device '); - } - let output = context.output ?? { info: message => process.stdout.write(`${message}\n`), }; output.info( - 'Building the iOS app and discovering stock #Preview declarations' + 'Preparing to build the iOS app and discover stock #Preview declarations' ); let manifest = await runPreviewCapture({ container, diff --git a/clients/swift/src/plugin.js b/clients/swift/src/plugin.js index 45c5af6c..96b9c63e 100644 --- a/clients/swift/src/plugin.js +++ b/clients/swift/src/plugin.js @@ -18,7 +18,10 @@ export default { 'Render screenshots from stock SwiftUI #Preview declarations' ) .option('--scheme ', 'Xcode scheme to build') - .option('--device ', 'Booted iOS Simulator UDID') + .option( + '--device ', + 'Simulator UDID (auto-detected when exactly one iOS Simulator is booted)' + ) .option('--configuration ', 'Build configuration', 'Debug') .option('--output ', 'Screenshot output directory') .option('--json', 'Print the capture manifest as JSON') diff --git a/clients/swift/src/preview-runner.js b/clients/swift/src/preview-runner.js index ff387606..6232998a 100644 --- a/clients/swift/src/preview-runner.js +++ b/clients/swift/src/preview-runner.js @@ -60,6 +60,78 @@ export function readPngMetadata(buffer) { }; } +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 child = spawn(executable, args, { @@ -148,6 +220,18 @@ async function assertSupportedToolchain() { 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 ensureEmptyOutput(outputPath) { if (!(await pathExists(outputPath))) { await mkdir(outputPath, { recursive: true }); @@ -402,11 +486,18 @@ export async function runPreviewCapture({ try { let xcodeVersion = await assertSupportedToolchain(); + let simulator = await resolveSimulator(device); + let resolvedDevice = simulator.udid; + let simulatorAction = + simulator.selection === 'automatic' ? 'Auto-selected' : 'Using'; + onProgress( + `${simulatorAction} booted iOS Simulator: ${formatSimulator(simulator)}` + ); let derivedDataPath = join(temporaryPath, 'DerivedData'); let build = await buildApplication({ container, scheme, - device, + device: resolvedDevice, configuration, derivedDataPath, }); @@ -422,13 +513,18 @@ export async function runPreviewCapture({ build.settings.IPHONEOS_DEPLOYMENT_TARGET ?? '18.0' ); await embedRuntime(build.appPath, runtimePath); - await runCommand('xcrun', ['simctl', 'install', device, build.appPath]); + await runCommand('xcrun', [ + 'simctl', + 'install', + resolvedDevice, + build.appPath, + ]); let bundleId = build.settings.PRODUCT_BUNDLE_IDENTIFIER; let containerResult = await runCommand('xcrun', [ 'simctl', 'get_app_container', - device, + resolvedDevice, bundleId, 'data', ]); @@ -438,7 +534,7 @@ export async function runPreviewCapture({ let preview = await captureRegistry({ registryType, index, - device, + device: resolvedDevice, bundleId, containerPath, outputPath, @@ -452,7 +548,8 @@ export async function runPreviewCapture({ xcodeVersion, container, scheme, - device, + device: resolvedDevice, + simulator, configuration, outputPath, previews, diff --git a/clients/swift/tests-js/preview-runner.test.js b/clients/swift/tests-js/preview-runner.test.js index 46ce2ce7..506ee747 100644 --- a/clients/swift/tests-js/preview-runner.test.js +++ b/clients/swift/tests-js/preview-runner.test.js @@ -1,12 +1,113 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + parseBootedIOSSimulators, parseRegistryTypes, parseRuntimeEvents, readPngMetadata, + selectBootedIOSSimulator, } 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('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', From bd0dfe8b608598fa862c20e6a5c70e72cdcc3f14 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sun, 30 Aug 2026 12:55:02 -0500 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20Productionize=20SwiftUI=20pre?= =?UTF-8?q?view=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the native renderer for real app targets, repeatable output, scheme selection, capture timeouts, and explicit compatibility failures. Ship the npm plugin through Swift releases, add CI coverage and complete fixture metadata, and verify package auto-discovery against a real iOS project. --- .github/workflows/release-swift-client.yml | 47 ++- clients/swift/.gitignore | 2 + clients/swift/CHANGELOG.md | 11 + .../PreviewFixture.xcodeproj/project.pbxproj | 191 ++++++++++++ clients/swift/PREVIEWS-SPIKE.md | 36 --- clients/swift/PREVIEWS.md | 36 +++ clients/swift/README.md | 62 ++++ .../RuntimeConstructor.c | 6 + .../VizzlyPreviewRuntime.swift | 81 ++++- clients/swift/package.json | 30 +- clients/swift/scripts/run-preview-e2e.js | 37 ++- clients/swift/src/index.js | 32 +- clients/swift/src/plugin.js | 25 +- clients/swift/src/preview-runner.js | 292 +++++++++++++++--- clients/swift/tests-js/index.test.js | 46 +++ clients/swift/tests-js/plugin.test.js | 22 ++ clients/swift/tests-js/preview-runner.test.js | 60 +++- package.json | 10 +- 18 files changed, 879 insertions(+), 147 deletions(-) create mode 100644 clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj delete mode 100644 clients/swift/PREVIEWS-SPIKE.md create mode 100644 clients/swift/PREVIEWS.md create mode 100644 clients/swift/tests-js/index.test.js create mode 100644 clients/swift/tests-js/plugin.test.js diff --git a/.github/workflows/release-swift-client.yml b/.github/workflows/release-swift-client.yml index 4d053d06..c1108443 100644 --- a/.github/workflows/release-swift-client.yml +++ b/.github/workflows/release-swift-client.yml @@ -35,6 +35,16 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: '22.13.1' + registry-url: 'https://registry.npmjs.org' + + - 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: Show Xcode version run: xcodebuild -version @@ -97,6 +107,13 @@ jobs: echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "tag=swift/v$NEW_VERSION" >> $GITHUB_OUTPUT + - name: Update npm package version + working-directory: ./clients/swift + run: npm version ${{ steps.new_version.outputs.version }} --no-git-tag-version + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Generate changelog continue-on-error: true uses: openai/codex-action@a26d2d4d8b78a694338b8e3715c3630254340b2c # v1 @@ -173,6 +190,25 @@ jobs: working-directory: ./clients/swift run: swift test + - name: Check preview CLI package + working-directory: ./clients/swift + run: pnpm run check + + - 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,12 +216,20 @@ 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 }}" + - 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/ + npm publish "${{ steps.pack.outputs.file }}" --provenance --access public + - name: Read changelog for release id: release_notes working-directory: ./clients/swift @@ -204,6 +248,7 @@ 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 env: 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 3d720479..6bf0a659 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -15,6 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 asset, runtime injection, PNG capture, and manifest generation. - 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. + +### Fixed + +- 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. ## [0.1.0] - 2026-06-01 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..6de59e6b --- /dev/null +++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj @@ -0,0 +1,191 @@ +// !$*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 */; }; + 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 = ""; }; + + 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 = (); + 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 */, + A10000000000000000000009 /* Resources */, + ); + buildRules = (); + dependencies = (); + name = PreviewFixture; + 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; + 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; + }; + }; + rootObject = A1000000000000000000000C /* Project object */; +} diff --git a/clients/swift/PREVIEWS-SPIKE.md b/clients/swift/PREVIEWS-SPIKE.md deleted file mode 100644 index 3cecfb35..00000000 --- a/clients/swift/PREVIEWS-SPIKE.md +++ /dev/null @@ -1,36 +0,0 @@ -# Stock `#Preview` capture spike - -This spike adds a `vizzly previews` CLI extension without inventing a second -preview declaration API. It builds the selected Debug app for an already -booted iOS Simulator, finds the generated `DeveloperToolsSupport.PreviewRegistry` -types in the built Mach-O, and launches the real app once per registry. - -A small Swift dylib is injected only into those capture launches. It intercepts -the stock `DeveloperToolsSupport.Preview` initializer, keeps the original -`@MainActor () -> any View` closure, mounts that view in the app window, and -writes a PNG. The CLI copies each PNG to the requested host directory and writes -`manifest.json`. - -```sh -vizzly previews MyApp.xcodeproj \ - --scheme MyApp \ - --output .vizzly/previews -``` - -When exactly one available iOS Simulator is booted, the CLI selects it. If -more than one is booted, it lists the choices and asks for `--device ` -instead of guessing. Passing `--device` still validates that the selected iOS -Simulator is booted. `VIZZLY_SIMULATOR_UDID` remains useful for the E2E script -when a machine intentionally has multiple booted Simulators. - -The current cutline is deliberately narrow: - -- Xcode 26.6 and Swift 6.3.3 -- Debug iOS apps on an arm64 iOS Simulator -- SwiftUI `#Preview` declarations -- one fresh app process per preview -- local PNG and manifest output; Vizzly upload is the next integration step - -The implementation fails closed on another Xcode version because the -interceptor uses a Swift ABI symbol. It does not use Xcode MCP, `mcpbridge`, -Xcode's private preview action, source rewriting, or a `#VizzlyPreview` macro. diff --git a/clients/swift/PREVIEWS.md b/clients/swift/PREVIEWS.md new file mode 100644 index 00000000..bf6f9367 --- /dev/null +++ b/clients/swift/PREVIEWS.md @@ -0,0 +1,36 @@ +# Stock `#Preview` capture + +The `@vizzly-testing/swift` CLI plugin builds an actual iOS app for an already +booted Simulator, discovers generated `DeveloperToolsSupport.PreviewRegistry` +types in the built Mach-O, and launches the app once per registry. + +A small native Swift dylib is compiled for the selected Simulator and injected +only into those capture launches. It intercepts the stock +`DeveloperToolsSupport.Preview` initializer, keeps the original +`@MainActor () -> any View` closure, mounts that view in the active app window, +and writes a PNG. The CLI copies the completed artifacts into the requested +host directory and writes `manifest.json`. + +```sh +vizzly previews MyApp.xcodeproj --scheme MyApp +``` + +The CLI auto-selects a project, shared scheme, or booted iOS Simulator only +when exactly one choice exists. Ambiguous choices are listed and require an +explicit argument instead of relying on heuristics. + +The supported cutline is deliberately narrow: + +- Xcode 26.6 and Swift 6.3.3 +- Debug iOS apps on an arm64 iOS Simulator running iOS 17 or newer +- stock SwiftUI `#Preview` declarations in the app executable or debug dylib +- scene-based app lifecycle +- previews without `PreviewTrait` values +- one fresh app process per preview +- local PNG and manifest output + +The implementation fails closed on another Xcode version because the +interceptor uses a Swift ABI symbol. It also fails when preview traits are +present rather than producing a screenshot that silently differs from Xcode. +It does not use Xcode MCP, `mcpbridge`, Xcode's private preview action, source +rewriting, or a `#VizzlyPreview` macro. diff --git a/clients/swift/README.md b/clients/swift/README.md index 36e5b0d6..67294ea4 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -13,6 +13,8 @@ Unlike tools that render components in isolation, Vizzly captures screenshots di - **TDD Mode** - Local visual testing with instant feedback - **Cloud Mode** - Team collaboration via Vizzly dashboard - **Graceful Degradation** - Tests pass even if Vizzly is unavailable +- **Stock SwiftUI Previews** - Render existing `#Preview` declarations from the + app target without adding a second preview API ## Installation @@ -46,6 +48,66 @@ targets: [ Vizzly does not currently ship a CocoaPods podspec. Use Swift Package Manager for native app integration. +## SwiftUI `#Preview` Capture + +Preview capture is a Vizzly CLI plugin. It does not require adding a runtime to +the app target or replacing stock `#Preview` declarations. + +Install the CLI and Swift plugin in the iOS project: + +```bash +pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift +``` + +With one Xcode project or workspace in the current directory, one shared +scheme, and one booted iOS Simulator, the complete command is: + +```bash +vizzly previews +``` + +The CLI only auto-selects when there is exactly one safe choice. Otherwise, +pass the project, scheme, or Simulator explicitly: + +```bash +vizzly previews MyApp.xcworkspace \ + --scheme MyApp \ + --device B40B976E-CD70-45F2-830C-48E8ED9B7EE7 \ + --output .vizzly/previews +``` + +Each run builds the real app target, discovers its generated preview +registries, launches one fresh app process per preview, and writes PNGs plus a +versioned `manifest.json`. A successful rerun safely replaces only an output +directory previously created by this command. If the directory contains other +files, the command refuses to delete them. + +Optional defaults live under `swiftPreviews` in `vizzly.config.js`: + +```js +import { defineConfig } from '@vizzly-testing/cli/config'; + +export default defineConfig({ + swiftPreviews: { + scheme: 'MyApp', + configuration: 'Debug', + output: '.vizzly/previews', + captureTimeout: 30_000, + }, +}); +``` + +The native renderer currently supports Xcode 26.6, arm64 iOS Simulators, iOS +17 or newer, scene-based SwiftUI apps, and previews compiled into the app +executable or debug dylib. Preview traits such as fixed layouts and device +orientation fail explicitly until their Xcode semantics can be reproduced. +The exact Xcode version is checked before capture because the implementation +intercepts a version-specific Swift ABI symbol. It does not use Xcode MCP, +`mcpbridge`, private Xcode actions, or source rewriting. + +Preview capture currently produces local artifacts. Sending the resulting +manifest and PNGs through a Vizzly build is the next integration layer. + ## Quick Start ### 1. Start Vizzly TDD Server diff --git a/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c b/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c index f9fe827d..4c30cc20 100644 --- a/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c +++ b/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c @@ -1,5 +1,10 @@ #include "CVizzlyPreviewRuntime.h" +#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) @@ -17,6 +22,7 @@ static struct { void *VizzlyOriginalPreviewInitializer(void) { return (void *)interposers[0].replacee; } +#endif __attribute__((constructor)) static void start_vizzly_preview_runtime(void) { diff --git a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift index a160c61a..89135fae 100644 --- a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift +++ b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift @@ -9,7 +9,10 @@ public enum VizzlyPreviewRuntime { public static func link() {} } +@available(iOS 17.0, *) public typealias VizzlyPreviewBody = @MainActor () -> any View + +@available(iOS 17.0, *) public typealias VizzlyPreviewInitializer = @convention(thin) @MainActor ( String?, [PreviewTrait], @@ -19,15 +22,28 @@ public typealias VizzlyPreviewInitializer = @convention(thin) @MainActor ( @_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 capturedPreviewTraitCount = 0 + +@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?, @@ -36,6 +52,7 @@ public func interceptPreviewInitializer( ) -> Preview { capturedPreviewBody = body capturedPreviewName = name ?? "Unnamed Preview" + capturedPreviewTraitCount = traits.count let original = unsafeBitCast( originalPreviewInitializerPointer(), @@ -44,6 +61,7 @@ public func interceptPreviewInitializer( return original(name, traits, body) } +@available(iOS 17.0, *) @MainActor private func emitEvent(_ event: [String: Any]) { guard @@ -58,6 +76,7 @@ private func emitEvent(_ event: [String: Any]) { fflush(stdout) } +@available(iOS 17.0, *) @MainActor private func resolvePreview() throws -> AnyView { guard @@ -72,6 +91,10 @@ private func resolvePreview() throws -> AnyView { _ = try registry.makePreview() + guard capturedPreviewTraitCount == 0 else { + throw PreviewRuntimeError.unsupportedTraits(capturedPreviewTraitCount) + } + guard let body = capturedPreviewBody else { throw PreviewRuntimeError.bodyUnavailable } @@ -82,11 +105,13 @@ private func resolvePreview() throws -> AnyView { "type": "preview-resolved", "name": capturedPreviewName, "registryType": registryName, + "traitCount": capturedPreviewTraitCount, "viewType": String(reflecting: type(of: view)), ]) return AnyView(view) } +@available(iOS 17.0, *) private struct InjectedPreviewRoot: View { let preview: AnyView @@ -97,6 +122,7 @@ private struct InjectedPreviewRoot: View { } } +@available(iOS 17.0, *) private struct CaptureProbe: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> CaptureController { CaptureController() @@ -181,15 +207,21 @@ private struct CaptureProbe: UIViewControllerRepresentable { } } +@available(iOS 17.0, *) @MainActor -private func installPreview() { +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 scene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first, - let window = scene.windows.first - else { + guard let window = scene.windows.first(where: \.isKeyWindow) + ?? scene.windows.first(where: { !$0.isHidden && $0.alpha > 0 }) + ?? scene.windows.first else { throw PreviewRuntimeError.windowUnavailable } @@ -204,6 +236,7 @@ private func installPreview() { } } +@available(iOS 17.0, *) @MainActor private func emitFailure(_ error: Error) { emitEvent([ @@ -213,22 +246,35 @@ private func emitFailure(_ error: Error) { ]) } +@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 + ProcessInfo.processInfo.environment["VIZZLY_REGISTRY_TYPE"] != nil, + #available(iOS 17.0, *) else { return } - activationObserver = NotificationCenter.default.addObserver( - forName: UIScene.didActivateNotification, - object: nil, - queue: .main - ) { _ in - MainActor.assumeIsolated { - installPreview() - } + MainActor.assumeIsolated { + startPreviewObservation() } } @@ -236,6 +282,7 @@ private enum PreviewRuntimeError: LocalizedError { case bodyUnavailable case pngEncodingFailed case registryUnavailable + case unsupportedTraits(Int) case windowUnavailable var errorDescription: String? { @@ -246,6 +293,8 @@ private enum PreviewRuntimeError: LocalizedError { 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" } diff --git a/clients/swift/package.json b/clients/swift/package.json index 46f037f3..977bf7b2 100644 --- a/clients/swift/package.json +++ b/clients/swift/package.json @@ -2,6 +2,24 @@ "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", @@ -12,16 +30,18 @@ "src", "Sources/VizzlyPreviewRuntime", "Sources/CVizzlyPreviewRuntime", - "Package.swift", + "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", - "format": "biome format --write src tests-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" @@ -29,6 +49,10 @@ "peerDependencies": { "@vizzly-testing/cli": ">=0.35.0-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 index e87e0b9f..4c9c2e6c 100644 --- a/clients/swift/scripts/run-preview-e2e.js +++ b/clients/swift/scripts/run-preview-e2e.js @@ -8,20 +8,21 @@ let device = process.env.VIZZLY_SIMULATOR_UDID; let outputPath = await mkdtemp(join(tmpdir(), 'vizzly-preview-e2e-')); try { - let manifest = await runPreviewCapture({ - container: resolve( - import.meta.dirname, - '..', - 'Fixtures', - 'PreviewFixture', - 'PreviewFixture.xcodeproj' - ), - scheme: 'PreviewFixture', - device, - configuration: 'Debug', - outputPath, - onProgress: message => process.stdout.write(`${message}\n`), - }); + 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', @@ -37,8 +38,14 @@ try { ); assert.notEqual(manifest.previews[0].sha256, manifest.previews[1].sha256); + let repeatedManifest = await capture(); + assert.deepEqual( + repeatedManifest.previews.map(preview => preview.sha256), + manifest.previews.map(preview => preview.sha256) + ); + process.stdout.write( - `Verified ${manifest.previews.length} stock #Preview screenshots\n` + `Verified ${manifest.previews.length} repeatable stock #Preview screenshots\n` ); } finally { await rm(outputPath, { recursive: true, force: true }); diff --git a/clients/swift/src/index.js b/clients/swift/src/index.js index ef7f6d4f..e2ea2315 100644 --- a/clients/swift/src/index.js +++ b/clients/swift/src/index.js @@ -1,17 +1,23 @@ import { runPreviewCapture } from './preview-runner.js'; -export async function run(container, options = {}, context = {}) { - let config = context.config?.swiftPreviews ?? {}; - let scheme = options.scheme ?? config.scheme; - let device = options.device ?? config.device; - let outputPath = options.output ?? config.output ?? '.vizzly/previews'; +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, + }; +} - if (!scheme) { - throw new Error('Swift preview capture requires --scheme '); - } +export async function run(container, options = {}, context = {}) { + let previewOptions = resolvePreviewOptions( + options, + context.config?.swiftPreviews ?? {} + ); let output = context.output ?? { - info: message => process.stdout.write(`${message}\n`), + info: message => process.stderr.write(`${message}\n`), }; output.info( @@ -19,10 +25,7 @@ export async function run(container, options = {}, context = {}) { ); let manifest = await runPreviewCapture({ container, - scheme, - device, - configuration: options.configuration ?? config.configuration ?? 'Debug', - outputPath, + ...previewOptions, onProgress: message => output.info(message), }); @@ -37,5 +40,4 @@ export async function run(container, options = {}, context = {}) { return manifest; } -export { runPreviewCapture } from './preview-runner.js'; -export { run as default }; +export { run as default, runPreviewCapture }; diff --git a/clients/swift/src/plugin.js b/clients/swift/src/plugin.js index 96b9c63e..7c1a4d11 100644 --- a/clients/swift/src/plugin.js +++ b/clients/swift/src/plugin.js @@ -1,11 +1,20 @@ 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', output: '.vizzly/previews', }, @@ -17,17 +26,25 @@ export default { .description( 'Render screenshots from stock SwiftUI #Preview declarations' ) - .option('--scheme ', 'Xcode scheme to build') + .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', 'Debug') + .option('--configuration ', 'Build configuration') + .option( + '--capture-timeout ', + 'Maximum time to render each preview', + parsePositiveInteger + ) .option('--output ', 'Screenshot output directory') .option('--json', 'Print the capture manifest as JSON') .action(async (container = '.', options) => { - let globalOptions = program.opts(); - await run(container, { ...globalOptions, ...options }, context); + 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 index 6232998a..72015d82 100644 --- a/clients/swift/src/preview-runner.js +++ b/clients/swift/src/preview-runner.js @@ -7,15 +7,21 @@ import { mkdtemp, readdir, readFile, + rename, rm, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { basename, extname, join, resolve } from 'node:path'; +import { basename, dirname, extname, join, resolve } from 'node:path'; let eventPrefix = 'VIZZLY_PREVIEW_EVENT '; +let minimumRuntimeDeploymentTarget = '17.0'; let supportedXcodeVersion = '26.6'; +function invalidPng() { + throw new Error('Preview capture did not produce a valid PNG'); +} + export function parseRegistryTypes(output) { let registries = new Set(); @@ -49,17 +55,83 @@ export function parseRuntimeEvents(output) { export function readPngMetadata(buffer) { let signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) { - throw new Error('Preview capture did not produce a valid PNG'); + 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: buffer.readUInt32BE(16), - height: buffer.readUInt32BE(20), + 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 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('-', '.'); @@ -134,9 +206,13 @@ export function selectBootedIOSSimulator(simulators, requestedDevice) { 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 = []; @@ -144,11 +220,21 @@ function runCommand(executable, args, options = {}) { child.stdout.on('data', chunk => stdout.push(chunk)); child.stderr.on('data', chunk => stderr.push(chunk)); - child.once('error', rejectPromise); - child.once('close', (exitCode, signal) => { + child.once('error', error => { + if (error.name === 'AbortError') { + rejectPromise( + new Error( + `${basename(executable)} timed out after ${options.timeoutMs}ms` + ) + ); + return; + } + rejectPromise(error); + }); + child.once('close', (exitCode, terminationSignal) => { let result = { exitCode, - signal, + signal: terminationSignal, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), }; @@ -209,12 +295,23 @@ function containerArguments(container) { : ['-project', container]; } +async function resolveScheme(container, requestedScheme) { + let result = await runCommand('xcodebuild', [ + ...containerArguments(container), + '-list', + '-json', + ]); + return selectScheme(parseSchemes(result.stdout), requestedScheme); +} + 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. This spike requires Xcode ${supportedXcodeVersion}` + `Unsupported preview ABI for Xcode ${detectedVersion}. ` + + `This release supports Xcode ${supportedXcodeVersion}` ); } return match[1]; @@ -232,15 +329,70 @@ async function resolveSimulator(requestedDevice) { return selectBootedIOSSimulator(simulators, requestedDevice); } -async function ensureEmptyOutput(outputPath) { +async function validateOutputPath(outputPath) { if (!(await pathExists(outputPath))) { - await mkdir(outputPath, { recursive: true }); return; } - let entries = await readdir(outputPath); - if (entries.length > 0) { - throw new Error(`Preview output directory must be empty: ${outputPath}`); + 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); + } + + let previewFiles = manifest.previews?.map(preview => preview.file); + if ( + manifest.protocolVersion !== 1 || + !Array.isArray(previewFiles) || + previewFiles.some(file => !file || basename(file) !== file) + ) { + throw unmanagedOutputError(outputPath); + } + + let expectedEntries = new Set(['manifest.json', ...previewFiles]); + if ( + entries.some(entry => !entry.isFile() || !expectedEntries.has(entry.name)) + ) { + 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 }); } } @@ -293,11 +445,22 @@ async function buildApplication(options) { 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 = [ - join(appPath, settings.EXECUTABLE_PATH ?? settings.EXECUTABLE_NAME), - join(appPath, `${settings.PRODUCT_NAME}.debug.dylib`), - ]; + let candidates = applicationBinaryCandidates(appPath, settings); let entries = await readdir(appPath, { withFileTypes: true }); for (let entry of entries) { if (entry.isFile() && entry.name.endsWith('.dylib')) { @@ -307,13 +470,20 @@ async function applicationBinaries(appPath, settings) { let binaries = []; for (let candidate of new Set(candidates)) { - if (candidate && (await pathExists(candidate))) { + if (await pathExists(candidate)) { binaries.push(candidate); } } return binaries; } +export function runtimeDeploymentTarget(deploymentTarget) { + let version = Number.parseFloat(deploymentTarget); + return version >= Number.parseFloat(minimumRuntimeDeploymentTarget) + ? deploymentTarget + : minimumRuntimeDeploymentTarget; +} + async function discoverRegistries(appPath, settings) { let registries = new Set(); for (let binary of await applicationBinaries(appPath, settings)) { @@ -355,7 +525,7 @@ async function compileRuntime(clientRoot, buildPath, deploymentTarget) { 'VizzlyPreviewRuntime', 'VizzlyPreviewRuntime.swift' ); - let target = `arm64-apple-ios${deploymentTarget}-simulator`; + let target = `arm64-apple-ios${runtimeDeploymentTarget(deploymentTarget)}-simulator`; await mkdir(moduleCache, { recursive: true }); await runCommand('xcrun', [ @@ -419,6 +589,7 @@ async function captureRegistry({ bundleId, containerPath, outputPath, + captureTimeout, }) { let runtimeFilename = 'vizzly-preview.png'; let runtimePath = join(containerPath, 'Documents', runtimeFilename); @@ -436,6 +607,7 @@ async function captureRegistry({ ], { allowFailure: true, + timeoutMs: captureTimeout, env: { ...process.env, SIMCTL_CHILD_DYLD_INSERT_LIBRARIES: @@ -476,51 +648,60 @@ export async function runPreviewCapture({ device, configuration = 'Debug', outputPath: outputInput, + captureTimeout = 30_000, onProgress = () => {}, }) { - let clientRoot = resolve(import.meta.dirname, '..'); - let container = await resolveContainer(containerInput); - let outputPath = resolve(outputInput); - await ensureEmptyOutput(outputPath); - let temporaryPath = await mkdtemp(join(tmpdir(), 'vizzly-previews-')); + let temporaryPath; + let stagingPath; try { + let clientRoot = resolve(import.meta.dirname, '..'); + 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 = - simulator.selection === 'automatic' ? 'Auto-selected' : 'Using'; + let simulatorAction = selectionAction(simulator.selection); onProgress( `${simulatorAction} booted iOS Simulator: ${formatSimulator(simulator)}` ); let derivedDataPath = join(temporaryPath, 'DerivedData'); - let build = await buildApplication({ + let { appPath, settings } = await buildApplication({ container, - scheme, + scheme: resolvedScheme, device: resolvedDevice, configuration, derivedDataPath, }); - let registries = await discoverRegistries(build.appPath, build.settings); - if (registries.length === 0) { - throw new Error(`No stock #Preview declarations were found in ${scheme}`); + let registryTypes = await discoverRegistries(appPath, settings); + if (registryTypes.length === 0) { + throw new Error( + `No stock #Preview declarations were found in ${resolvedScheme}` + ); } - onProgress(`Discovered ${registries.length} stock #Preview declarations`); + onProgress( + `Discovered ${registryTypes.length} stock #Preview declarations` + ); let runtimePath = await compileRuntime( clientRoot, temporaryPath, - build.settings.IPHONEOS_DEPLOYMENT_TARGET ?? '18.0' + settings.IPHONEOS_DEPLOYMENT_TARGET ?? minimumRuntimeDeploymentTarget ); - await embedRuntime(build.appPath, runtimePath); - await runCommand('xcrun', [ - 'simctl', - 'install', - resolvedDevice, - build.appPath, - ]); + await embedRuntime(appPath, runtimePath); + await runCommand('xcrun', ['simctl', 'install', resolvedDevice, appPath]); - let bundleId = build.settings.PRODUCT_BUNDLE_IDENTIFIER; + let bundleId = settings.PRODUCT_BUNDLE_IDENTIFIER; let containerResult = await runCommand('xcrun', [ 'simctl', 'get_app_container', @@ -528,16 +709,17 @@ export async function runPreviewCapture({ bundleId, 'data', ]); - let containerPath = containerResult.stdout.trim(); + let dataContainerPath = containerResult.stdout.trim(); let previews = []; - for (let [index, registryType] of registries.entries()) { + for (let [index, registryType] of registryTypes.entries()) { let preview = await captureRegistry({ registryType, index, device: resolvedDevice, bundleId, - containerPath, - outputPath, + containerPath: dataContainerPath, + outputPath: stagingPath, + captureTimeout, }); previews.push(preview); onProgress(`Captured ${preview.name}`); @@ -547,7 +729,7 @@ export async function runPreviewCapture({ protocolVersion: 1, xcodeVersion, container, - scheme, + scheme: resolvedScheme, device: resolvedDevice, simulator, configuration, @@ -555,14 +737,22 @@ export async function runPreviewCapture({ previews, }; await writeFile( - join(outputPath, 'manifest.json'), + join(stagingPath, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n` ); + await replaceOutputDirectory(stagingPath, outputPath); + stagingPath = undefined; return manifest; } catch (error) { - error.message = `Swift preview capture failed: ${error.message}`; - throw error; + throw new Error(`Swift preview capture failed: ${error.message}`, { + cause: error, + }); } finally { - await rm(temporaryPath, { recursive: true, force: true }); + if (temporaryPath) { + await rm(temporaryPath, { recursive: true, force: true }); + } + if (stagingPath) { + await rm(stagingPath, { recursive: true, force: true }); + } } } diff --git a/clients/swift/tests-js/index.test.js b/clients/swift/tests-js/index.test.js new file mode 100644 index 00000000..9430ecf9 --- /dev/null +++ b/clients/swift/tests-js/index.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { 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', + } + ), + { + captureTimeout: 45_000, + configuration: 'Release', + device: 'CONFIGURED-DEVICE', + outputPath: 'configured-output', + scheme: 'ConfiguredScheme', + } + ); + }); + + 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'); + }); +}); diff --git a/clients/swift/tests-js/plugin.test.js b/clients/swift/tests-js/plugin.test.js new file mode 100644 index 00000000..dab84503 --- /dev/null +++ b/clients/swift/tests-js/plugin.test.js @@ -0,0 +1,22 @@ +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 native runtime', () => { + 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')); + }); + + it('documents conservative capture defaults for vizzly init', () => { + assert.deepEqual(plugin.configSchema.swiftPreviews, { + captureTimeout: 30_000, + configuration: 'Debug', + output: '.vizzly/previews', + }); + }); +}); diff --git a/clients/swift/tests-js/preview-runner.test.js b/clients/swift/tests-js/preview-runner.test.js index 506ee747..8a3e1097 100644 --- a/clients/swift/tests-js/preview-runner.test.js +++ b/clients/swift/tests-js/preview-runner.test.js @@ -1,11 +1,16 @@ import assert from 'node:assert/strict'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; import { + applicationBinaryCandidates, parseBootedIOSSimulators, parseRegistryTypes, parseRuntimeEvents, + parseSchemes, readPngMetadata, + runtimeDeploymentTarget, selectBootedIOSSimulator, + selectScheme, } from '../src/preview-runner.js'; let simulatorList = JSON.stringify({ @@ -44,6 +49,28 @@ let simulatorList = JSON.stringify({ }); 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('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), [ { @@ -122,6 +149,27 @@ describe('Swift preview runner contracts', () => { ]); }); + 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('compiles the injected runtime for at least iOS 17', () => { + assert.equal(runtimeDeploymentTarget('13.0'), '17.0'); + assert.equal(runtimeDeploymentTarget('17.0'), '17.0'); + assert.equal(runtimeDeploymentTarget('26.0'), '26.0'); + }); + it('ignores app logs and reads versioned runtime completion events', () => { let output = [ 'ordinary app log', @@ -136,10 +184,14 @@ describe('Swift preview runner contracts', () => { }); it('validates observable PNG dimensions and content hash', () => { - let png = Buffer.alloc(24); + 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); @@ -149,5 +201,11 @@ describe('Swift preview runner contracts', () => { 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/package.json b/package.json index 67524988..bf2e4e9d 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "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", @@ -80,10 +80,10 @@ "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": { From bc1992278700cb3fb888423b09f78b82cf20e1af Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sun, 30 Aug 2026 23:07:52 -0500 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8=20Upload=20SwiftUI=20previews?= =?UTF-8?q?=20to=20Vizzly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route native preview captures through the existing screenshot client for local TDD and cloud builds. Record upload outcomes, preserve explicit local-only capture, and cover the real HTTP boundary plus stock-preview naming and metadata. --- clients/swift/CHANGELOG.md | 9 + clients/swift/PREVIEWS.md | 24 ++- clients/swift/README.md | 31 +++- clients/swift/package.json | 2 +- clients/swift/src/index.js | 178 +++++++++++++++++-- clients/swift/src/plugin.js | 5 + clients/swift/src/upload.js | 208 +++++++++++++++++++++++ clients/swift/tests-js/index.test.js | 3 + clients/swift/tests-js/plugin.test.js | 5 + clients/swift/tests-js/upload.test.js | 235 ++++++++++++++++++++++++++ src/client/index.js | 31 +++- src/plugin-api.js | 18 ++ src/types/client.d.ts | 22 +++ tests/unit/plugin-api.test.js | 11 ++ 14 files changed, 752 insertions(+), 30 deletions(-) create mode 100644 clients/swift/src/upload.js create mode 100644 clients/swift/tests-js/upload.test.js diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 6bf0a659..0803c48a 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -19,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ### Fixed @@ -26,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ## [0.1.0] - 2026-06-01 diff --git a/clients/swift/PREVIEWS.md b/clients/swift/PREVIEWS.md index bf6f9367..6a7960af 100644 --- a/clients/swift/PREVIEWS.md +++ b/clients/swift/PREVIEWS.md @@ -9,12 +9,31 @@ only into those capture launches. It intercepts the stock `DeveloperToolsSupport.Preview` initializer, keeps the original `@MainActor () -> any View` closure, mounts that view in the active app window, and writes a PNG. The CLI copies the completed artifacts into the requested -host directory and writes `manifest.json`. +host directory, writes `manifest.json`, and sends each screenshot through the +same Vizzly client used by the other SDKs. ```sh vizzly previews MyApp.xcodeproj --scheme MyApp ``` +Upload routing stays simple: + +1. A live local TDD server wins. +2. Otherwise, an available Vizzly token creates and finalizes a cloud build. +3. With neither available, the PNGs and manifest stay local and the command + tells you how to enable uploads. + +For a complete one-off local review, let the TDD command own the server for the +whole capture: + +```sh +vizzly tdd run "vizzly previews" --no-open +``` + +Use `vizzly previews --no-upload` when you deliberately want only the local +artifacts. The manifest records `tdd`, `cloud`, `local-only`, or `disabled` so +automation never has to guess what happened. + The CLI auto-selects a project, shared scheme, or booted iOS Simulator only when exactly one choice exists. Ambiguous choices are listed and require an explicit argument instead of relying on heuristics. @@ -27,7 +46,8 @@ The supported cutline is deliberately narrow: - scene-based app lifecycle - previews without `PreviewTrait` values - one fresh app process per preview -- local PNG and manifest output +- local PNG and manifest output on every successful capture +- local TDD uploads and Vizzly cloud build uploads The implementation fails closed on another Xcode version because the interceptor uses a Swift ABI symbol. It also fails when preview traits are diff --git a/clients/swift/README.md b/clients/swift/README.md index 67294ea4..ed7512ee 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -82,6 +82,29 @@ versioned `manifest.json`. A successful rerun safely replaces only an output directory previously created by this command. If the directory contains other files, the command refuses to delete them. +After capture, the command uses the first available upload target: + +- A live local TDD server receives the screenshots immediately. +- Otherwise, a configured Vizzly token creates, uploads, and finalizes a cloud + build. +- Without either one, the command keeps the local artifacts and prints a clear + next step. + +The easiest local workflow is one command: + +```bash +vizzly tdd run "vizzly previews" --no-open +``` + +That keeps the TDD server alive for the full Xcode build and capture, then +writes the normal local comparison report. If you already have +`vizzly tdd start` running, plain `vizzly previews` finds it automatically. + +In CI, set `VIZZLY_TOKEN` and run the same preview command. The plugin uses the +CLI's existing cloud lifecycle: create a build, start its screenshot proxy, +upload every PNG, flush pending work, finalize the build, and print the result +URL. + Optional defaults live under `swiftPreviews` in `vizzly.config.js`: ```js @@ -93,10 +116,15 @@ export default defineConfig({ configuration: 'Debug', output: '.vizzly/previews', captureTimeout: 30_000, + upload: true, }, }); ``` +Pass `--no-upload` for an intentional artifact-only run. Whether uploads went +to TDD, cloud, nowhere, or were disabled is recorded in `manifest.json` under +`upload.mode`. + The native renderer currently supports Xcode 26.6, arm64 iOS Simulators, iOS 17 or newer, scene-based SwiftUI apps, and previews compiled into the app executable or debug dylib. Preview traits such as fixed layouts and device @@ -105,9 +133,6 @@ The exact Xcode version is checked before capture because the implementation intercepts a version-specific Swift ABI symbol. It does not use Xcode MCP, `mcpbridge`, private Xcode actions, or source rewriting. -Preview capture currently produces local artifacts. Sending the resulting -manifest and PNGs through a Vizzly build is the next integration layer. - ## Quick Start ### 1. Start Vizzly TDD Server diff --git a/clients/swift/package.json b/clients/swift/package.json index 977bf7b2..5e5d7c8c 100644 --- a/clients/swift/package.json +++ b/clients/swift/package.json @@ -47,7 +47,7 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.35.0-0" + "@vizzly-testing/cli": ">=0.35.3-beta.1" }, "publishConfig": { "access": "public", diff --git a/clients/swift/src/index.js b/clients/swift/src/index.js index e2ea2315..6d3dd74e 100644 --- a/clients/swift/src/index.js +++ b/clients/swift/src/index.js @@ -1,4 +1,12 @@ +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 { @@ -7,9 +15,30 @@ export function resolvePreviewOptions(options, config) { device: options.device ?? config.device, outputPath: options.output ?? config.output ?? '.vizzly/previews', scheme: options.scheme ?? config.scheme, + upload: options.upload ?? config.upload ?? true, }; } +async function saveManifest(manifest) { + await writeFile( + join(manifest.outputPath, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); +} + +function requireCloudServices(services) { + if ( + !services?.git?.detect || + !services?.screenshots?.createClient || + !services?.testRunner || + !services?.serverManager + ) { + throw new Error( + 'Cloud preview uploads require a current @vizzly-testing/cli installation' + ); + } +} + export async function run(container, options = {}, context = {}) { let previewOptions = resolvePreviewOptions( options, @@ -18,26 +47,145 @@ export async function run(container, options = {}, context = {}) { let output = context.output ?? { info: message => process.stderr.write(`${message}\n`), + warn: message => process.stderr.write(`${message}\n`), }; + let services = context.services; + let vizzlyConfig = context.config ?? {}; + let serverManager = null; + let testRunner = null; + let buildId = null; + let buildUrl = null; + let finalizationAttempted = false; + let startTime = Date.now(); - output.info( - 'Preparing to build the iOS app and discover stock #Preview declarations' - ); - let manifest = await runPreviewCapture({ - container, - ...previewOptions, - onProgress: message => output.info(message), - }); - - if (options.json) { - process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); - } else { + try { output.info( - `Captured ${manifest.previews.length} SwiftUI previews in ${manifest.outputPath}` + 'Preparing to build the iOS app and discover stock #Preview declarations' ); - } + let manifest = await runPreviewCapture({ + container, + ...previewOptions, + onProgress: message => output.info(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, + screenshots: services?.screenshots, + 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, + screenshots: services.screenshots, + serverUrl: `http://localhost:${runOptions.port}`, + }); + finalizationAttempted = true; + await testRunner.finalizeBuild( + buildId, + false, + true, + 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'); + } + } - return manifest; + 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}`); + } + } + } + + 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 index 7c1a4d11..2e15d3e1 100644 --- a/clients/swift/src/plugin.js +++ b/clients/swift/src/plugin.js @@ -17,6 +17,7 @@ export default { captureTimeout: 30_000, configuration: 'Debug', output: '.vizzly/previews', + upload: true, }, }, @@ -41,6 +42,10 @@ export default { 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 }; diff --git a/clients/swift/src/upload.js b/clients/swift/src/upload.js new file mode 100644 index 00000000..1cf632e6 --- /dev/null +++ b/clients/swift/src/upload.js @@ -0,0 +1,208 @@ +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; +} + +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, + screenshots, + serverUrl, +}) { + if (!screenshots?.createClient) { + throw new Error( + 'This Vizzly CLI does not provide screenshot uploads to plugins. Upgrade @vizzly-testing/cli.' + ); + } + + let client = screenshots.createClient({ + failOnDiff: process.env.VIZZLY_FAIL_ON_DIFF === 'true', + serverUrl, + }); + let records = buildPreviewUploadRecords(manifest); + + for (let record of records) { + let result = await client.screenshot(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 client.flush(); + 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 index 9430ecf9..95caee3c 100644 --- a/clients/swift/tests-js/index.test.js +++ b/clients/swift/tests-js/index.test.js @@ -13,6 +13,7 @@ describe('Swift preview CLI options', () => { device: 'CONFIGURED-DEVICE', output: 'configured-output', scheme: 'ConfiguredScheme', + upload: false, } ), { @@ -21,6 +22,7 @@ describe('Swift preview CLI options', () => { device: 'CONFIGURED-DEVICE', outputPath: 'configured-output', scheme: 'ConfiguredScheme', + upload: false, } ); }); @@ -42,5 +44,6 @@ describe('Swift preview CLI options', () => { assert.equal(resolved.captureTimeout, 5_000); assert.equal(resolved.configuration, 'Debug'); assert.equal(resolved.outputPath, 'command-output'); + assert.equal(resolved.upload, true); }); }); diff --git a/clients/swift/tests-js/plugin.test.js b/clients/swift/tests-js/plugin.test.js index dab84503..d9c348b7 100644 --- a/clients/swift/tests-js/plugin.test.js +++ b/clients/swift/tests-js/plugin.test.js @@ -10,6 +10,10 @@ describe('Swift preview plugin package', () => { 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.35.3-beta.1' + ); }); it('documents conservative capture defaults for vizzly init', () => { @@ -17,6 +21,7 @@ describe('Swift preview plugin package', () => { captureTimeout: 30_000, configuration: 'Debug', output: '.vizzly/previews', + upload: true, }); }); }); diff --git a/clients/swift/tests-js/upload.test.js b/clients/swift/tests-js/upload.test.js new file mode 100644 index 00000000..f7338abd --- /dev/null +++ b/clients/swift/tests-js/upload.test.js @@ -0,0 +1,235 @@ +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 { createPluginServices } from '../../../src/plugin-api.js'; +import { + buildCloudRunOptions, + buildPreviewUploadRecords, + findLocalTddServer, + uploadCapturedPreviews, +} from '../src/upload.js'; + +let temporaryPaths = []; +let servers = []; + +afterEach(async () => { + await Promise.all( + servers + .splice(0) + .map( + server => new Promise(resolvePromise => server.close(resolvePromise)) + ) + ); + 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, + }, + ], + }; +} + +function pluginServices() { + return createPluginServices({ + testRunner: { + once() {}, + on() {}, + off() {}, + createBuild() {}, + finalizeBuild() {}, + }, + serverManager: { + start() {}, + stop() {}, + }, + }); +} + +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 new Promise(resolvePromise => servers.pop().close(resolvePromise)); + assert.equal(await findLocalTddServer([nested]), null); + }); + + it('uploads every rendered PNG and flushes through the plugin service', 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, + screenshots: pluginServices().screenshots, + 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.properties.threshold, 2.5); + assert.equal(requests[0].body.properties.minClusterSize, 3); + }); +}); diff --git a/src/client/index.js b/src/client/index.js index ac920f5b..75cb6a6c 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -138,8 +138,10 @@ function getClient() { // If we have a server URL, create the client (regardless of initial enabled state) if (serverUrl) { currentServerUrl = serverUrl; - currentClient = createSimpleClient(serverUrl, { + currentClient = createScreenshotClient({ + disableOnFailure: true, failOnDiff: currentFailOnDiff, + serverUrl, }); } } @@ -207,11 +209,14 @@ function httpPost(url, body, timeoutMs) { } /** - * Create a simple HTTP client for screenshots - * @private + * Create a screenshot client connected to one Vizzly server. */ -function createSimpleClient(serverUrl, clientOptions = {}) { - let { failOnDiff = false } = clientOptions; +export function createScreenshotClient(options = {}) { + let { disableOnFailure = false, failOnDiff = false, serverUrl } = options; + + if (!serverUrl) { + throw new Error('A Vizzly screenshot server URL is required'); + } return { async screenshot(name, imageBuffer, options = {}) { @@ -298,7 +303,9 @@ function createSimpleClient(serverUrl, clientOptions = {}) { `[vizzly] Screenshot timed out for "${name}" after ${requestTimeout / 1000}s` ); } - disableVizzly(); + if (disableOnFailure) { + disableVizzly(); + } return null; } @@ -328,7 +335,9 @@ function createSimpleClient(serverUrl, clientOptions = {}) { } // Disable the SDK after first failure to prevent spam - disableVizzly(); + if (disableOnFailure) { + disableVizzly(); + } // Don't throw - just return silently to not break tests return null; @@ -451,13 +460,17 @@ export function configure(config = {}) { if ('serverUrl' in config) { currentServerUrl = config.serverUrl || null; currentClient = config.serverUrl - ? createSimpleClient(config.serverUrl, { + ? createScreenshotClient({ + disableOnFailure: true, failOnDiff: currentFailOnDiff, + serverUrl: config.serverUrl, }) : null; } else if ('failOnDiff' in config && currentClient && currentServerUrl) { - currentClient = createSimpleClient(currentServerUrl, { + currentClient = createScreenshotClient({ + disableOnFailure: true, failOnDiff: currentFailOnDiff, + serverUrl: currentServerUrl, }); } diff --git a/src/plugin-api.js b/src/plugin-api.js index 8e46243c..f6704143 100644 --- a/src/plugin-api.js +++ b/src/plugin-api.js @@ -9,6 +9,7 @@ * exposed to plugins to prevent coupling to implementation details. */ +import { createScreenshotClient } from './client/index.js'; import { detectBranch, detectCommit, @@ -22,6 +23,7 @@ import { * * Only exposes: * - git: Git information detection (branch, commit, PR number, etc.) + * - screenshots: Screenshot delivery to a Vizzly server * - testRunner: Build lifecycle management (createBuild, finalizeBuild, events) * - serverManager: Screenshot server control (start, stop) * @@ -61,6 +63,22 @@ export function createPluginServices(services) { }, }), + screenshots: Object.freeze({ + /** + * Create an isolated screenshot client for a local TDD or cloud proxy + * server. Plugins pass build IDs per screenshot when cloud routing is + * required. + * + * @param {Object} options - Client options + * @param {string} options.serverUrl - Vizzly screenshot server URL + * @param {boolean} [options.failOnDiff] - Fail local TDD on visual diffs + * @returns {Object} Screenshot client with screenshot and flush methods + */ + createClient(options) { + return createScreenshotClient(options); + }, + }), + testRunner: Object.freeze({ // EventEmitter methods for build lifecycle events once: testRunner.once.bind(testRunner), diff --git a/src/types/client.d.ts b/src/types/client.d.ts index f0c2d581..ca51affe 100644 --- a/src/types/client.d.ts +++ b/src/types/client.d.ts @@ -48,6 +48,28 @@ export interface ScreenshotResult { [key: string]: unknown; } +export interface ScreenshotClient { + screenshot( + name: string, + imageBuffer: Buffer | string, + options?: { + properties?: Record; + threshold?: number; + minClusterSize?: number; + fullPage?: boolean; + buildId?: string; + requestTimeout?: number; + } + ): Promise; + flush(): Promise; +} + +/** Create an isolated client connected to one screenshot server. */ +export function createScreenshotClient(options: { + serverUrl: string; + failOnDiff?: boolean; +}): ScreenshotClient; + /** * Take a screenshot for visual regression testing * diff --git a/tests/unit/plugin-api.test.js b/tests/unit/plugin-api.test.js index e2f4a6b6..445b3b98 100644 --- a/tests/unit/plugin-api.test.js +++ b/tests/unit/plugin-api.test.js @@ -54,10 +54,21 @@ describe('Plugin API', () => { assert.ok(Object.isFrozen(services), 'services should be frozen'); assert.ok(services.git, 'should have git property'); + assert.ok(services.screenshots, 'should have screenshots property'); assert.ok(services.testRunner, 'should have testRunner property'); assert.ok(services.serverManager, 'should have serverManager property'); }); + it('exposes a screenshot client factory', () => { + let services = createPluginServices(mockServices); + let client = services.screenshots.createClient({ + serverUrl: 'http://localhost:47392', + }); + + assert.strictEqual(typeof client.screenshot, 'function'); + assert.strictEqual(typeof client.flush, 'function'); + }); + it('exposes git.detect as a function', () => { let services = createPluginServices(mockServices); From 5f8aea2c708dcf7b9d4c02499735d0d635d8498d Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Mon, 31 Aug 2026 07:52:02 -0500 Subject: [PATCH 05/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Productionize=20Swif?= =?UTF-8?q?t=20preview=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten managed output and plugin capability checks, run the native preview fixture during Swift releases, and replace duplicated Swift docs with focused XCTest and #Preview guides. --- .github/workflows/release-swift-client.yml | 30 +- README.md | 12 + clients/swift/CHANGELOG.md | 6 +- clients/swift/INTEGRATION.md | 513 +++----------- clients/swift/PREVIEWS.md | 223 +++++-- clients/swift/QUICKSTART.md | 115 +--- clients/swift/README.md | 624 ++---------------- .../VizzlyPreviewRuntime.swift | 6 +- clients/swift/package.json | 5 +- clients/swift/scripts/run-preview-e2e.js | 15 +- clients/swift/src/index.js | 17 +- clients/swift/src/plugin.js | 2 + clients/swift/src/preview-runner.js | 25 +- clients/swift/src/upload.js | 6 +- clients/swift/tests-js/plugin.test.js | 4 +- clients/swift/tests-js/upload.test.js | 45 +- pnpm-lock.yaml | 3 - test-d/client.test-d.ts | 23 +- 18 files changed, 503 insertions(+), 1171 deletions(-) diff --git a/.github/workflows/release-swift-client.yml b/.github/workflows/release-swift-client.yml index c1108443..c49e5cad 100644 --- a/.github/workflows/release-swift-client.yml +++ b/.github/workflows/release-swift-client.yml @@ -19,7 +19,7 @@ concurrency: jobs: release: - runs-on: macos-latest + runs-on: macos-26 permissions: contents: write id-token: write @@ -46,8 +46,10 @@ jobs: corepack enable corepack prepare pnpm@11.3.0 --activate - - name: Show Xcode version - run: xcodebuild -version + - name: Select Xcode version + run: | + sudo xcode-select -s /Applications/Xcode_26.6.app + xcodebuild -version - name: Configure git run: | @@ -194,6 +196,28 @@ jobs: 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: | diff --git a/README.md b/README.md index 58d2cb73..2d6af1a7 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/swift +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/CHANGELOG.md b/clients/swift/CHANGELOG.md index 0803c48a..6c0ef23e 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added an experimental `vizzly previews` plugin and native Simulator runtime +- Added a `vizzly previews` plugin and native Simulator runtime that render existing stock SwiftUI `#Preview` declarations without Xcode MCP. - Added a two-preview iOS fixture that exercises app-module discovery, a named asset, runtime injection, PNG capture, and manifest generation. @@ -35,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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 index 6a7960af..847effc9 100644 --- a/clients/swift/PREVIEWS.md +++ b/clients/swift/PREVIEWS.md @@ -1,56 +1,195 @@ -# Stock `#Preview` capture +# SwiftUI `#Preview` capture -The `@vizzly-testing/swift` CLI plugin builds an actual iOS app for an already -booted Simulator, discovers generated `DeveloperToolsSupport.PreviewRegistry` -types in the built Mach-O, and launches the app once per registry. +Vizzly renders the stock `#Preview` declarations already in your app. You do +not need a Vizzly macro, a catalog, or changes to the app target. -A small native Swift dylib is compiled for the selected Simulator and injected -only into those capture launches. It intercepts the stock -`DeveloperToolsSupport.Preview` initializer, keeps the original -`@MainActor () -> any View` closure, mounts that view in the active app window, -and writes a PNG. The CLI copies the completed artifacts into the requested -host directory, writes `manifest.json`, and sends each screenshot through the -same Vizzly client used by the other SDKs. +## Requirements -```sh -vizzly previews MyApp.xcodeproj --scheme MyApp +- 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 does not support preview traits such as fixed layouts or +orientation. It stops with an error when it finds a trait instead of capturing +something that differs from Xcode. + +## Install + +Add the CLI and Swift plugin to the iOS project: + +```bash +pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift +``` + +The plugin is discovered automatically. It is not linked into the app target. + +## 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, and upload result. `upload.mode` is one of `tdd`, +`cloud`, `local-only`, or `disabled`. + +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" ``` -Upload routing stays simple: +`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. -1. A live local TDD server wins. -2. Otherwise, an available Vizzly token creates and finalizes a cloud build. -3. With neither available, the PNGs and manifest stay local and the command - tells you how to enable uploads. +### No shared scheme is available -For a complete one-off local review, let the TDD command own the server for the -whole capture: +In Xcode, choose **Product → Scheme → Manage Schemes**, mark the app scheme as +shared, and commit the scheme file. -```sh -vizzly tdd run "vizzly previews" --no-open +### No booted Simulator is found + +Boot one from Xcode or Simulator. Confirm it appears under: + +```bash +xcrun simctl list devices booted ``` -Use `vizzly previews --no-upload` when you deliberately want only the local -artifacts. The manifest records `tdd`, `cloud`, `local-only`, or `disabled` so -automation never has to guess what happened. +### 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. + +### The output directory is rejected -The CLI auto-selects a project, shared scheme, or booted iOS Simulator only -when exactly one choice exists. Ambiguous choices are listed and require an -explicit argument instead of relying on heuristics. +Choose a new `--output` path, or move the existing directory yourself. Vizzly +will not remove files it cannot prove it created. -The supported cutline is deliberately narrow: +## How it works -- Xcode 26.6 and Swift 6.3.3 -- Debug iOS apps on an arm64 iOS Simulator running iOS 17 or newer -- stock SwiftUI `#Preview` declarations in the app executable or debug dylib -- scene-based app lifecycle -- previews without `PreviewTrait` values -- one fresh app process per preview -- local PNG and manifest output on every successful capture -- local TDD uploads and Vizzly cloud build uploads +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. A small native runtime captures the preview body, +mounts it in the app window, and writes a PNG. -The implementation fails closed on another Xcode version because the -interceptor uses a Swift ABI symbol. It also fails when preview traits are -present rather than producing a screenshot that silently differs from Xcode. -It does not use Xcode MCP, `mcpbridge`, Xcode's private preview action, source -rewriting, or a `#VizzlyPreview` macro. +This path does not use Xcode MCP, `mcpbridge`, private Xcode actions, or source +rewriting. The exact Xcode check is the safety boundary around the private Swift +ABI used for preview discovery. 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 ed7512ee..b50e9e3d 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -1,619 +1,91 @@ # 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 -- **Stock SwiftUI Previews** - Render existing `#Preview` declarations from the - app target without adding a second preview API +## SwiftUI previews -## 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 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") - ] - ) -] -``` - -Vizzly does not currently ship a CocoaPods podspec. Use Swift Package Manager -for native app integration. - -## SwiftUI `#Preview` Capture - -Preview capture is a Vizzly CLI plugin. It does not require adding a runtime to -the app target or replacing stock `#Preview` declarations. - -Install the CLI and Swift plugin in the iOS project: +Install the CLI and preview plugin in your iOS project: ```bash pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift ``` -With one Xcode project or workspace in the current directory, one shared -scheme, and one booted iOS Simulator, the complete command is: +Boot an iOS Simulator, then run: ```bash -vizzly previews +pnpm exec vizzly previews ``` -The CLI only auto-selects when there is exactly one safe choice. Otherwise, -pass the project, scheme, or Simulator explicitly: +Vizzly builds the app, finds its existing `#Preview` declarations, renders each +one in the Simulator, and writes PNGs to `.vizzly/previews`. It does not require +a Vizzly macro or changes to your app target. -```bash -vizzly previews MyApp.xcworkspace \ - --scheme MyApp \ - --device B40B976E-CD70-45F2-830C-48E8ED9B7EE7 \ - --output .vizzly/previews -``` - -Each run builds the real app target, discovers its generated preview -registries, launches one fresh app process per preview, and writes PNGs plus a -versioned `manifest.json`. A successful rerun safely replaces only an output -directory previously created by this command. If the directory contains other -files, the command refuses to delete them. - -After capture, the command uses the first available upload target: - -- A live local TDD server receives the screenshots immediately. -- Otherwise, a configured Vizzly token creates, uploads, and finalizes a cloud - build. -- Without either one, the command keeps the local artifacts and prints a clear - next step. - -The easiest local workflow is one command: +See [PREVIEWS.md](PREVIEWS.md) for requirements, configuration, CI, and +troubleshooting. -```bash -vizzly tdd run "vizzly previews" --no-open -``` - -That keeps the TDD server alive for the full Xcode build and capture, then -writes the normal local comparison report. If you already have -`vizzly tdd start` running, plain `vizzly previews` finds it automatically. +## XCTest screenshots -In CI, set `VIZZLY_TOKEN` and run the same preview command. The plugin uses the -CLI's existing cloud lifecycle: create a build, start its screenshot proxy, -upload every PNG, flush pending work, finalize the build, and print the result -URL. +Add this repository as a Swift Package dependency: -Optional defaults live under `swiftPreviews` in `vizzly.config.js`: - -```js -import { defineConfig } from '@vizzly-testing/cli/config'; - -export default defineConfig({ - swiftPreviews: { - scheme: 'MyApp', - configuration: 'Debug', - output: '.vizzly/previews', - captureTimeout: 30_000, - upload: true, - }, -}); +```text +https://github.com/vizzly-testing/cli ``` -Pass `--no-upload` for an intentional artifact-only run. Whether uploads went -to TDD, cloud, nowhere, or were disabled is recorded in `manifest.json` under -`upload.mode`. - -The native renderer currently supports Xcode 26.6, arm64 iOS Simulators, iOS -17 or newer, scene-based SwiftUI apps, and previews compiled into the app -executable or debug dylib. Preview traits such as fixed layouts and device -orientation fail explicitly until their Xcode semantics can be reproduced. -The exact Xcode version is checked before capture because the implementation -intercepts a version-specific Swift ABI symbol. It does not use Xcode MCP, -`mcpbridge`, private Xcode actions, or source rewriting. - -## Quick Start - -### 1. Start Vizzly TDD Server - -```bash -cd /path/to/your/ios/project -vizzly tdd start --open -``` - -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. - -### 2. Add Vizzly to Your UI Tests +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 -class MyUITests: XCTestCase { - let app = XCUIApplication() - +final class HomeScreenTests: XCTestCase { func testHomeScreen() { + let app = XCUIApplication() 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 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"] - ) -} -``` - -### Custom Threshold - -```swift -func testAnimatedContent() { - // Allow a higher Delta E comparison threshold for animated content - app.vizzlyScreenshot( - name: "animated-banner", - threshold: 5 - ) -} -``` - -If `threshold` or `minClusterSize` is omitted, the server's configured -comparison settings are used. - -### 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: 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: - -```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 | +| SwiftUI preview traits | Not applicable | Not yet supported | -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/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift index 89135fae..4844cc84 100644 --- a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift +++ b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift @@ -6,6 +6,7 @@ import SwiftUI import UIKit public enum VizzlyPreviewRuntime { + /// Keeps this runtime linked when it is used as a static SwiftPM product. public static func link() {} } @@ -159,7 +160,7 @@ private struct CaptureProbe: UIViewControllerRepresentable { @MainActor private func captureWindow() throws -> String { - awaitOneRenderPass() + flushPendingRenderTransactions() guard let window = view.window else { throw PreviewRuntimeError.windowUnavailable @@ -201,7 +202,7 @@ private struct CaptureProbe: UIViewControllerRepresentable { } @MainActor - private func awaitOneRenderPass() { + private func flushPendingRenderTransactions() { CATransaction.flush() } } @@ -302,6 +303,7 @@ private enum PreviewRuntimeError: LocalizedError { } #else public enum VizzlyPreviewRuntime { + /// Keeps this runtime linked when it is used as a static SwiftPM product. public static func link() {} } diff --git a/clients/swift/package.json b/clients/swift/package.json index 5e5d7c8c..c899a053 100644 --- a/clients/swift/package.json +++ b/clients/swift/package.json @@ -47,7 +47,7 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.35.3-beta.1" + "@vizzly-testing/cli": ">=0.35.3-beta.3" }, "publishConfig": { "access": "public", @@ -55,7 +55,6 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.10", - "@vizzly-testing/cli": "workspace:*", - "commander": "^15.0.0" + "@vizzly-testing/cli": "workspace:*" } } diff --git a/clients/swift/scripts/run-preview-e2e.js b/clients/swift/scripts/run-preview-e2e.js index 4c9c2e6c..c84309a3 100644 --- a/clients/swift/scripts/run-preview-e2e.js +++ b/clients/swift/scripts/run-preview-e2e.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; +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'; @@ -44,8 +44,19 @@ try { manifest.previews.map(preview => preview.sha256) ); + 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, + 2 + ); + process.stdout.write( - `Verified ${manifest.previews.length} repeatable stock #Preview screenshots\n` + `Verified ${manifest.previews.length} repeatable stock #Preview screenshots and safe output replacement\n` ); } finally { await rm(outputPath, { recursive: true, force: true }); diff --git a/clients/swift/src/index.js b/clients/swift/src/index.js index 6d3dd74e..1112a6a9 100644 --- a/clients/swift/src/index.js +++ b/clients/swift/src/index.js @@ -27,12 +27,17 @@ async function saveManifest(manifest) { } function requireCloudServices(services) { - if ( - !services?.git?.detect || - !services?.screenshots?.createClient || - !services?.testRunner || - !services?.serverManager - ) { + let requiredMethods = [ + services?.git?.detect, + services?.screenshots?.createClient, + 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' ); diff --git a/clients/swift/src/plugin.js b/clients/swift/src/plugin.js index 2e15d3e1..ad1ad435 100644 --- a/clients/swift/src/plugin.js +++ b/clients/swift/src/plugin.js @@ -16,7 +16,9 @@ export default { swiftPreviews: { captureTimeout: 30_000, configuration: 'Debug', + device: null, output: '.vizzly/previews', + scheme: null, upload: true, }, }, diff --git a/clients/swift/src/preview-runner.js b/clients/swift/src/preview-runner.js index 72015d82..3bd4fe38 100644 --- a/clients/swift/src/preview-runner.js +++ b/clients/swift/src/preview-runner.js @@ -348,21 +348,36 @@ async function validateOutputPath(outputPath) { throw unmanagedOutputError(outputPath); } - let previewFiles = manifest.previews?.map(preview => preview.file); + 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 ( - manifest.protocolVersion !== 1 || - !Array.isArray(previewFiles) || - previewFiles.some(file => !file || basename(file) !== file) + previewFiles.some( + file => !file || file === 'manifest.json' || basename(file) !== file + ) || + uniquePreviewFiles.size !== previewFiles.length ) { throw unmanagedOutputError(outputPath); } - let expectedEntries = new Set(['manifest.json', ...previewFiles]); + 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) { diff --git a/clients/swift/src/upload.js b/clients/swift/src/upload.js index 1cf632e6..e62ef570 100644 --- a/clients/swift/src/upload.js +++ b/clients/swift/src/upload.js @@ -138,6 +138,10 @@ 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) @@ -182,7 +186,7 @@ export async function uploadCapturedPreviews({ } let client = screenshots.createClient({ - failOnDiff: process.env.VIZZLY_FAIL_ON_DIFF === 'true', + failOnDiff: shouldFailOnDiff(), serverUrl, }); let records = buildPreviewUploadRecords(manifest); diff --git a/clients/swift/tests-js/plugin.test.js b/clients/swift/tests-js/plugin.test.js index d9c348b7..7f36c16b 100644 --- a/clients/swift/tests-js/plugin.test.js +++ b/clients/swift/tests-js/plugin.test.js @@ -12,7 +12,7 @@ describe('Swift preview plugin package', () => { assert.ok(!packageJson.files.includes('Package.swift')); assert.equal( packageJson.peerDependencies['@vizzly-testing/cli'], - '>=0.35.3-beta.1' + '>=0.35.3-beta.3' ); }); @@ -20,7 +20,9 @@ describe('Swift preview plugin package', () => { 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/upload.test.js b/clients/swift/tests-js/upload.test.js index f7338abd..6ba50fb8 100644 --- a/clients/swift/tests-js/upload.test.js +++ b/clients/swift/tests-js/upload.test.js @@ -17,11 +17,7 @@ let servers = []; afterEach(async () => { await Promise.all( - servers - .splice(0) - .map( - server => new Promise(resolvePromise => server.close(resolvePromise)) - ) + servers.splice(0).map(server => server[Symbol.asyncDispose]()) ); await Promise.all( temporaryPaths @@ -186,7 +182,7 @@ describe('Swift preview uploads', () => { `http://localhost:${port}` ); - await new Promise(resolvePromise => servers.pop().close(resolvePromise)); + await servers.pop()[Symbol.asyncDispose](); assert.equal(await findLocalTddServer([nested]), null); }); @@ -232,4 +228,41 @@ describe('Swift preview uploads', () => { assert.equal(requests[0].body.properties.threshold, 2.5); assert.equal(requests[0].body.properties.minClusterSize, 3); }); + + it('honors both supported fail-on-diff environment values', async () => { + let receivedValues = []; + let screenshots = { + createClient(options) { + receivedValues.push(options.failOnDiff); + return { + async flush() { + return { success: true }; + }, + async screenshot() { + 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'), + screenshots, + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 9d4d9c5b..b2cdf0fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,9 +310,6 @@ importers: '@vizzly-testing/cli': specifier: workspace:* version: link:../.. - commander: - specifier: ^15.0.0 - version: 15.0.0 clients/storybook: dependencies: diff --git a/test-d/client.test-d.ts b/test-d/client.test-d.ts index b671bfb0..c827f945 100644 --- a/test-d/client.test-d.ts +++ b/test-d/client.test-d.ts @@ -2,10 +2,15 @@ * Type tests for @vizzly-testing/cli/client */ import { expectError, expectType } from 'tsd'; -import type { ScreenshotResult } from '../src/types/client'; +import type { + FlushResult, + ScreenshotClient, + ScreenshotResult, +} from '../src/types/client'; import { autoDiscoverTddServer, configure, + createScreenshotClient, getVizzlyInfo, isVizzlyReady, LOG_LEVELS, @@ -21,6 +26,20 @@ let screenshotResult: ScreenshotResult = { }; expectType(screenshotResult); +let isolatedClient = createScreenshotClient({ + serverUrl: 'http://localhost:47392', + failOnDiff: true, +}); +expectType(isolatedClient); +expectType>( + isolatedClient.screenshot('preview', './preview.png', { + buildId: 'build-123', + properties: { platform: 'iOS' }, + }) +); +expectType>(isolatedClient.flush()); +expectError(createScreenshotClient({})); + // ============================================================================ // vizzlyScreenshot // ============================================================================ @@ -74,8 +93,6 @@ expectError( // ============================================================================ // Should return Promise -import type { FlushResult } from '../src/types/client'; - expectType>(vizzlyFlush()); let flushResult: FlushResult = { success: true, From 973ec751b51bf00bbd373a3b6ab96530af24cedb Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Tue, 1 Sep 2026 00:25:10 -0500 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=94=92=20Link=20Swift=20preview=20r?= =?UTF-8?q?untime=20through=20Xcode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move preview capture to a normal dynamic Swift Package dependency so Xcode owns building, embedding, and signing the runtime. Remove CLI injection and app mutation, add setup diagnostics, and prove the public integration on Simulator and device builds. --- Package.swift | 13 ++ clients/swift/CHANGELOG.md | 6 +- .../PreviewFixture.xcodeproj/project.pbxproj | 34 +++- .../PreviewFixture/PreviewFixtureApp.swift | 5 + clients/swift/PREVIEWS.md | 52 +++++- clients/swift/Package.swift | 2 +- clients/swift/README.md | 30 +++- ...ntimeConstructor.c => PreviewInterposer.c} | 7 - .../include/CVizzlyPreviewRuntime.h | 2 - .../VizzlyPreviewRuntime.swift | 14 +- clients/swift/package.json | 2 - clients/swift/scripts/run-preview-e2e.js | 2 +- clients/swift/src/preview-runner.js | 166 ++++++++---------- clients/swift/tests-js/plugin.test.js | 6 +- clients/swift/tests-js/preview-runner.test.js | 41 ++++- 15 files changed, 258 insertions(+), 124 deletions(-) rename clients/swift/Sources/CVizzlyPreviewRuntime/{RuntimeConstructor.c => PreviewInterposer.c} (83%) 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/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 6c0ef23e..3c34fc4a 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -12,7 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a `vizzly previews` plugin and native Simulator runtime that render existing stock SwiftUI `#Preview` declarations without Xcode MCP. - Added a two-preview iOS fixture that exercises app-module discovery, a named - asset, runtime injection, PNG capture, and manifest generation. + asset, linked-runtime capture, 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 @@ -29,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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`. diff --git a/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj b/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj index 6de59e6b..cad203ce 100644 --- a/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj +++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture.xcodeproj/project.pbxproj @@ -7,10 +7,24 @@ 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 = ( @@ -48,7 +62,9 @@ A10000000000000000000008 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; - files = (); + files = ( + A10000000000000000000014 /* VizzlyPreviewRuntime in Frameworks */, + ); runOnlyForDeploymentPostprocessing = 0; }; A10000000000000000000009 /* Resources */ = { @@ -66,11 +82,15 @@ 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"; @@ -92,6 +112,9 @@ hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = A10000000000000000000004; + packageReferences = ( + A10000000000000000000016 /* XCLocalSwiftPackageReference "../.." */, + ); productRefGroup = A10000000000000000000006 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -186,6 +209,15 @@ 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/PreviewFixtureApp.swift b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift index f54b4c74..938091f9 100644 --- a/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift +++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import VizzlyPreviewRuntime struct PreviewCard: View { let title: String @@ -44,6 +45,10 @@ struct StatefulCounter: View { @main struct PreviewFixtureApp: App { + init() { + VizzlyPreviewRuntime.install() + } + var body: some Scene { WindowGroup { Text("Ordinary app root") diff --git a/clients/swift/PREVIEWS.md b/clients/swift/PREVIEWS.md index 847effc9..df239382 100644 --- a/clients/swift/PREVIEWS.md +++ b/clients/swift/PREVIEWS.md @@ -1,7 +1,7 @@ # SwiftUI `#Preview` capture Vizzly renders the stock `#Preview` declarations already in your app. You do -not need a Vizzly macro, a catalog, or changes to the app target. +not need a Vizzly macro, a catalog, or a second set of preview definitions. ## Requirements @@ -24,7 +24,36 @@ Add the CLI and Swift plugin to the iOS project: pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift ``` -The plugin is discovered automatically. It is not linked into the app target. +Then add this repository as a Swift Package dependency in Xcode: + +```text +https://github.com/vizzly-testing/cli +``` + +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. ## Capture previews @@ -178,6 +207,14 @@ renderer depends on that release's Swift preview ABI. 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 @@ -187,9 +224,12 @@ will not remove files it cannot prove it created. 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. A small native runtime captures the preview body, -mounts it in the app window, and writes a PNG. +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. The exact Xcode check is the safety boundary around the private Swift -ABI used for preview discovery. +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 9621908a..058430be 100644 --- a/clients/swift/Package.swift +++ b/clients/swift/Package.swift @@ -18,7 +18,7 @@ let package = Package( targets: ["VizzlyXCTest"]), .library( name: "VizzlyPreviewRuntime", - type: .static, + type: .dynamic, targets: ["VizzlyPreviewRuntime"]), ], targets: [ diff --git a/clients/swift/README.md b/clients/swift/README.md index b50e9e3d..35cf3f99 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -18,6 +18,30 @@ Install the CLI and preview plugin in your iOS project: pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift ``` +Add this repository as a Swift Package dependency, then add the dynamic +`VizzlyPreviewRuntime` product to the app target with **Embed & Sign**: + +```text +https://github.com/vizzly-testing/cli +``` + +Install the runtime once from the app initializer: + +```swift +import VizzlyPreviewRuntime + +@main +struct MyApp: App { + init() { + VizzlyPreviewRuntime.install() + } + + var body: some Scene { + WindowGroup { ContentView() } + } +} +``` + Boot an iOS Simulator, then run: ```bash @@ -25,8 +49,9 @@ pnpm exec vizzly previews ``` Vizzly builds the app, finds its existing `#Preview` declarations, renders each -one in the Simulator, and writes PNGs to `.vizzly/previews`. It does not require -a Vizzly macro or changes to your app target. +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. See [PREVIEWS.md](PREVIEWS.md) for requirements, configuration, CI, and troubleshooting. @@ -77,6 +102,7 @@ See [QUICKSTART.md](QUICKSTART.md) for the shortest setup path and | Cloud builds | Yes | Yes | | Exact Xcode requirement | No | Xcode 26.6 | | SwiftUI preview traits | Not applicable | Not yet supported | +| App integration | UI test target | One app initializer call | Preview capture intentionally has a narrow compatibility range because it uses the preview ABI shipped with Xcode. The command checks the Xcode version and diff --git a/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c b/clients/swift/Sources/CVizzlyPreviewRuntime/PreviewInterposer.c similarity index 83% rename from clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c rename to clients/swift/Sources/CVizzlyPreviewRuntime/PreviewInterposer.c index 4c30cc20..dcdbd675 100644 --- a/clients/swift/Sources/CVizzlyPreviewRuntime/RuntimeConstructor.c +++ b/clients/swift/Sources/CVizzlyPreviewRuntime/PreviewInterposer.c @@ -1,5 +1,3 @@ -#include "CVizzlyPreviewRuntime.h" - #if defined(__APPLE__) #include #endif @@ -23,8 +21,3 @@ void *VizzlyOriginalPreviewInitializer(void) { return (void *)interposers[0].replacee; } #endif - -__attribute__((constructor)) -static void start_vizzly_preview_runtime(void) { - VizzlyPreviewRuntimeStart(); -} diff --git a/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h b/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h index e6f45fea..4e740ca2 100644 --- a/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h +++ b/clients/swift/Sources/CVizzlyPreviewRuntime/include/CVizzlyPreviewRuntime.h @@ -1,6 +1,4 @@ #ifndef CVIZZLY_PREVIEW_RUNTIME_H #define CVIZZLY_PREVIEW_RUNTIME_H -void VizzlyPreviewRuntimeStart(void); - #endif diff --git a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift index 4844cc84..73fca231 100644 --- a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift +++ b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift @@ -6,8 +6,11 @@ import SwiftUI import UIKit public enum VizzlyPreviewRuntime { - /// Keeps this runtime linked when it is used as a static SwiftPM product. - public static func link() {} + /// Enables Vizzly capture when the app is launched by `vizzly previews`. + @MainActor + public static func install() { + startVizzlyPreviewRuntime() + } } @available(iOS 17.0, *) @@ -303,8 +306,11 @@ private enum PreviewRuntimeError: LocalizedError { } #else public enum VizzlyPreviewRuntime { - /// Keeps this runtime linked when it is used as a static SwiftPM product. - public static func link() {} + /// Has no effect outside an iOS Simulator capture launch. + @MainActor + public static func install() { + startVizzlyPreviewRuntime() + } } @_cdecl("VizzlyPreviewRuntimeStart") diff --git a/clients/swift/package.json b/clients/swift/package.json index c899a053..ded9ba17 100644 --- a/clients/swift/package.json +++ b/clients/swift/package.json @@ -28,8 +28,6 @@ "vizzlyPlugin": "./src/plugin.js", "files": [ "src", - "Sources/VizzlyPreviewRuntime", - "Sources/CVizzlyPreviewRuntime", "PREVIEWS.md", "README.md", "CHANGELOG.md", diff --git a/clients/swift/scripts/run-preview-e2e.js b/clients/swift/scripts/run-preview-e2e.js index c84309a3..97bdd7ff 100644 --- a/clients/swift/scripts/run-preview-e2e.js +++ b/clients/swift/scripts/run-preview-e2e.js @@ -56,7 +56,7 @@ try { ); process.stdout.write( - `Verified ${manifest.previews.length} repeatable stock #Preview screenshots and safe output replacement\n` + `Verified ${manifest.previews.length} repeatable stock #Preview screenshots through the linked runtime and safe output replacement\n` ); } finally { await rm(outputPath, { recursive: true, force: true }); diff --git a/clients/swift/src/preview-runner.js b/clients/swift/src/preview-runner.js index 3bd4fe38..5d56e80d 100644 --- a/clients/swift/src/preview-runner.js +++ b/clients/swift/src/preview-runner.js @@ -15,8 +15,9 @@ import { tmpdir } from 'node:os'; import { basename, dirname, extname, join, resolve } from 'node:path'; let eventPrefix = 'VIZZLY_PREVIEW_EVENT '; -let minimumRuntimeDeploymentTarget = '17.0'; let supportedXcodeVersion = '26.6'; +let previewRuntimeInstallName = + '@rpath/VizzlyPreviewRuntime.framework/VizzlyPreviewRuntime'; function invalidPng() { throw new Error('Preview capture did not produce a valid PNG'); @@ -104,6 +105,13 @@ export function parseSchemes(output) { .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)) { @@ -301,7 +309,30 @@ async function resolveScheme(container, requestedScheme) { '-list', '-json', ]); - return selectScheme(parseSchemes(result.stdout), requestedScheme); + 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() { @@ -492,13 +523,6 @@ async function applicationBinaries(appPath, settings) { return binaries; } -export function runtimeDeploymentTarget(deploymentTarget) { - let version = Number.parseFloat(deploymentTarget); - return version >= Number.parseFloat(minimumRuntimeDeploymentTarget) - ? deploymentTarget - : minimumRuntimeDeploymentTarget; -} - async function discoverRegistries(appPath, settings) { let registries = new Set(); for (let binary of await applicationBinaries(appPath, settings)) { @@ -512,81 +536,52 @@ async function discoverRegistries(appPath, settings) { return [...registries].sort(); } -async function compileRuntime(clientRoot, buildPath, deploymentTarget) { - let sdkResult = await runCommand('xcrun', [ - '--sdk', - 'iphonesimulator', - '--show-sdk-path', - ]); - let sdkPath = sdkResult.stdout.trim(); - let moduleCache = join(buildPath, 'module-cache'); - let objectPath = join(buildPath, 'RuntimeConstructor.o'); - let dylibPath = join(buildPath, 'libVizzlyPreviewRuntime.dylib'); - let cSource = join( - clientRoot, - 'Sources', - 'CVizzlyPreviewRuntime', - 'RuntimeConstructor.c' - ); - let headerPath = join( - clientRoot, - 'Sources', - 'CVizzlyPreviewRuntime', - 'include' +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.' ); - let swiftSource = join( - clientRoot, - 'Sources', - 'VizzlyPreviewRuntime', - 'VizzlyPreviewRuntime.swift' +} + +export async function assertPreviewRuntimeIntegrated(appPath, settings) { + let frameworkBinary = join( + appPath, + 'Frameworks', + 'VizzlyPreviewRuntime.framework', + 'VizzlyPreviewRuntime' ); - let target = `arm64-apple-ios${runtimeDeploymentTarget(deploymentTarget)}-simulator`; + if (!(await pathExists(frameworkBinary))) { + throw previewRuntimeSetupError(); + } - await mkdir(moduleCache, { recursive: true }); - await runCommand('xcrun', [ - '--sdk', - 'iphonesimulator', - 'clang', - '-c', - cSource, - '-I', - headerPath, - '-target', - target, - '-isysroot', - sdkPath, - '-o', - objectPath, - ]); - await runCommand('xcrun', [ - '--toolchain', - 'XcodeDefault', - 'swiftc', - '-emit-library', - swiftSource, - objectPath, - '-module-name', - 'VizzlyPreviewRuntime', - '-target', - target, - '-sdk', - sdkPath, - '-parse-as-library', - '-module-cache-path', - moduleCache, - '-o', - dylibPath, - ]); - return dylibPath; -} + 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(); + } -async function embedRuntime(appPath, dylibPath) { - let frameworksPath = join(appPath, 'Frameworks'); - let embeddedPath = join(frameworksPath, basename(dylibPath)); - await mkdir(frameworksPath, { recursive: true }); - await copyFile(dylibPath, embeddedPath); - await runCommand('codesign', ['--force', '--sign', '-', embeddedPath]); - await runCommand('codesign', ['--force', '--deep', '--sign', '-', appPath]); + 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) { @@ -625,8 +620,6 @@ async function captureRegistry({ timeoutMs: captureTimeout, env: { ...process.env, - SIMCTL_CHILD_DYLD_INSERT_LIBRARIES: - '@executable_path/Frameworks/libVizzlyPreviewRuntime.dylib', SIMCTL_CHILD_VIZZLY_REGISTRY_TYPE: registryType, SIMCTL_CHILD_VIZZLY_OUTPUT_FILENAME: runtimeFilename, }, @@ -670,7 +663,6 @@ export async function runPreviewCapture({ let stagingPath; try { - let clientRoot = resolve(import.meta.dirname, '..'); let container = await resolveContainer(containerInput); let outputPath = resolve(outputInput); let outputParent = dirname(outputPath); @@ -698,6 +690,8 @@ export async function runPreviewCapture({ 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( @@ -708,12 +702,6 @@ export async function runPreviewCapture({ `Discovered ${registryTypes.length} stock #Preview declarations` ); - let runtimePath = await compileRuntime( - clientRoot, - temporaryPath, - settings.IPHONEOS_DEPLOYMENT_TARGET ?? minimumRuntimeDeploymentTarget - ); - await embedRuntime(appPath, runtimePath); await runCommand('xcrun', ['simctl', 'install', resolvedDevice, appPath]); let bundleId = settings.PRODUCT_BUNDLE_IDENTIFIER; diff --git a/clients/swift/tests-js/plugin.test.js b/clients/swift/tests-js/plugin.test.js index 7f36c16b..d965dcc5 100644 --- a/clients/swift/tests-js/plugin.test.js +++ b/clients/swift/tests-js/plugin.test.js @@ -4,11 +4,11 @@ import packageJson from '../package.json' with { type: 'json' }; import plugin from '../src/plugin.js'; describe('Swift preview plugin package', () => { - it('publishes a CLI-discoverable native runtime', () => { + 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('Sources/VizzlyPreviewRuntime')); + assert.ok(!packageJson.files.includes('Sources/CVizzlyPreviewRuntime')); assert.ok(!packageJson.files.includes('Package.swift')); assert.equal( packageJson.peerDependencies['@vizzly-testing/cli'], diff --git a/clients/swift/tests-js/preview-runner.test.js b/clients/swift/tests-js/preview-runner.test.js index 8a3e1097..ea2be273 100644 --- a/clients/swift/tests-js/preview-runner.test.js +++ b/clients/swift/tests-js/preview-runner.test.js @@ -1,14 +1,17 @@ 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, - runtimeDeploymentTarget, + schemeBuildsApplication, selectBootedIOSSimulator, selectScheme, } from '../src/preview-runner.js'; @@ -60,6 +63,20 @@ describe('Swift preview runner contracts', () => { }); }); + 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']), @@ -164,10 +181,24 @@ describe('Swift preview runner contracts', () => { ]); }); - it('compiles the injected runtime for at least iOS 17', () => { - assert.equal(runtimeDeploymentTarget('13.0'), '17.0'); - assert.equal(runtimeDeploymentTarget('17.0'), '17.0'); - assert.equal(runtimeDeploymentTarget('26.0'), '26.0'); + 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', () => { From 34ac7cc7a4c25f332c5893b0cae880b8debfb9f9 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Tue, 1 Sep 2026 13:43:57 -0500 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=91=B7=20Pin=20Swift=20unit=20runne?= =?UTF-8?q?r=20for=20Xcode=2016?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macos-latest label moved to macOS 26, which no longer includes Xcode 16.2 or 16.4. Keep the existing compatibility matrix on the macOS 15 arm64 image where both toolchains are installed. --- .github/workflows/sdk-unit.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sdk-unit.yml b/.github/workflows/sdk-unit.yml index ea55e039..b1385e17 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-arm64 timeout-minutes: 8 needs: changes if: needs.changes.outputs.swift == 'true' From d39b206c3264fa5cf4bdaf5fa930cad7c32cfe97 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Fri, 11 Sep 2026 00:04:02 -0500 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=9B=20Harden=20Swift=20preview?= =?UTF-8?q?=20beta=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep successful captures when individual previews fail, support common layout traits, and make the beta installable through exact npm and SwiftPM release tags. Also repair the Swift CI runner and rebase drift against the current upload contract. --- .github/workflows/release-swift-client.yml | 75 +++++---- .github/workflows/sdk-unit.yml | 2 +- README.md | 2 +- clients/swift/CHANGELOG.md | 11 +- .../PreviewFixture/PreviewFixtureApp.swift | 11 ++ clients/swift/PREVIEWS.md | 53 +++++- clients/swift/README.md | 11 +- .../VizzlyPreviewRuntime.swift | 158 +++++++++++++++--- clients/swift/scripts/run-preview-e2e.js | 36 +++- clients/swift/src/index.js | 16 +- clients/swift/src/preview-runner.js | 122 ++++++++++---- clients/swift/tests-js/index.test.js | 24 ++- clients/swift/tests-js/upload.test.js | 6 +- pnpm-lock.yaml | 2 +- 14 files changed, 419 insertions(+), 110 deletions(-) diff --git a/.github/workflows/release-swift-client.yml b/.github/workflows/release-swift-client.yml index c49e5cad..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 @@ -65,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 @@ -81,37 +82,34 @@ 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: Update npm package version - working-directory: ./clients/swift - run: npm version ${{ steps.new_version.outputs.version }} --no-git-tag-version + - 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 @@ -242,9 +240,12 @@ jobs: run: | 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 @@ -252,7 +253,11 @@ jobs: 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/ - npm publish "${{ steps.pack.outputs.file }}" --provenance --access public + 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 @@ -274,6 +279,6 @@ jobs: 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 b1385e17..5f15859f 100644 --- a/.github/workflows/sdk-unit.yml +++ b/.github/workflows/sdk-unit.yml @@ -260,7 +260,7 @@ jobs: swift: name: Swift SDK # Xcode 16.2 and 16.4 are both installed on this pinned image. - runs-on: macos-15-arm64 + runs-on: macos-15 timeout-minutes: 8 needs: changes if: needs.changes.outputs.swift == 'true' diff --git a/README.md b/README.md index 2d6af1a7..d7d39585 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ 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/swift +pnpm add --save-dev @vizzly-testing/swift@beta pnpm exec vizzly previews ``` diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 3c34fc4a..ca8a1442 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a `vizzly previews` plugin and native Simulator runtime that render existing stock SwiftUI `#Preview` declarations without Xcode MCP. -- Added a two-preview iOS fixture that exercises app-module discovery, a named - asset, linked-runtime capture, PNG output, and manifest generation. +- 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 @@ -28,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift index 938091f9..b0b08c1c 100644 --- a/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift +++ b/clients/swift/Fixtures/PreviewFixture/PreviewFixture/PreviewFixtureApp.swift @@ -64,3 +64,14 @@ struct PreviewFixtureApp: App { #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/PREVIEWS.md b/clients/swift/PREVIEWS.md index df239382..d58446d8 100644 --- a/clients/swift/PREVIEWS.md +++ b/clients/swift/PREVIEWS.md @@ -12,16 +12,17 @@ not need a Vizzly macro, a catalog, or a second set of preview definitions. - A scene-based iOS app - A shared Xcode scheme that builds the app in Debug -The current renderer does not support preview traits such as fixed layouts or -orientation. It stops with an error when it finds a trait instead of capturing -something that differs from Xcode. +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 +pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta ``` Then add this repository as a Swift Package dependency in Xcode: @@ -30,6 +31,10 @@ Then add this repository as a Swift Package dependency in Xcode: 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: @@ -55,6 +60,30 @@ 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 @@ -145,8 +174,13 @@ The default output is `.vizzly/previews`: ``` The manifest records the Xcode version, scheme, Simulator, preview names, -image dimensions, hashes, and upload result. `upload.mode` is one of `tdd`, -`cloud`, `local-only`, or `disabled`. +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 @@ -220,6 +254,13 @@ initializer. 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 diff --git a/clients/swift/README.md b/clients/swift/README.md index 35cf3f99..5323e488 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -15,7 +15,7 @@ You can use either one or both. Install the CLI and preview plugin in your iOS project: ```bash -pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift +pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta ``` Add this repository as a Swift Package dependency, then add the dynamic @@ -25,6 +25,8 @@ Add this repository as a Swift Package dependency, then add the dynamic https://github.com/vizzly-testing/cli ``` +For the beta, choose **Exact Version** and enter `0.1.1-beta.0`. + Install the runtime once from the app initializer: ```swift @@ -53,8 +55,8 @@ 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. -See [PREVIEWS.md](PREVIEWS.md) for requirements, configuration, CI, and -troubleshooting. +See [PREVIEWS.md](PREVIEWS.md) for package-version details, requirements, +configuration, CI, and troubleshooting. ## XCTest screenshots @@ -101,7 +103,8 @@ See [QUICKSTART.md](QUICKSTART.md) for the shortest setup path and | Local TDD | Yes | Yes | | Cloud builds | Yes | Yes | | Exact Xcode requirement | No | Xcode 26.6 | -| SwiftUI preview traits | Not applicable | Not yet supported | +| 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 | Preview capture intentionally has a narrow compatibility range because it uses diff --git a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift index 73fca231..ebd6278b 100644 --- a/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift +++ b/clients/swift/Sources/VizzlyPreviewRuntime/VizzlyPreviewRuntime.swift @@ -6,6 +6,11 @@ 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() { @@ -36,7 +41,11 @@ private var capturedPreviewName = "Unnamed Preview" @available(iOS 17.0, *) @MainActor -private var capturedPreviewTraitCount = 0 +private var capturedPreviewTraits: [PreviewTrait] = [] + +@available(iOS 17.0, *) +@MainActor +private var captureTargetView: UIView? @available(iOS 17.0, *) @MainActor @@ -56,7 +65,7 @@ public func interceptPreviewInitializer( ) -> Preview { capturedPreviewBody = body capturedPreviewName = name ?? "Unnamed Preview" - capturedPreviewTraitCount = traits.count + capturedPreviewTraits = traits let original = unsafeBitCast( originalPreviewInitializerPointer(), @@ -82,7 +91,96 @@ private func emitEvent(_ event: [String: Any]) { @available(iOS 17.0, *) @MainActor -private func resolvePreview() throws -> AnyView { +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" @@ -95,24 +193,24 @@ private func resolvePreview() throws -> AnyView { _ = try registry.makePreview() - guard capturedPreviewTraitCount == 0 else { - throw PreviewRuntimeError.unsupportedTraits(capturedPreviewTraitCount) - } - 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": capturedPreviewTraitCount, + "traitCount": capturedPreviewTraits.count, "viewType": String(reflecting: type(of: view)), ]) - return AnyView(view) + return ResolvedPreview(size: size, view: AnyView(view)) } @available(iOS 17.0, *) @@ -165,21 +263,21 @@ private struct CaptureProbe: UIViewControllerRepresentable { private func captureWindow() throws -> String { flushPendingRenderTransactions() - guard let window = view.window else { + guard let targetView = captureTargetView ?? view.window else { throw PreviewRuntimeError.windowUnavailable } - window.layoutIfNeeded() + targetView.layoutIfNeeded() let format = UIGraphicsImageRendererFormat() - format.scale = window.screen.scale + format.scale = targetView.window?.screen.scale ?? UIScreen.main.scale format.opaque = true let renderer = UIGraphicsImageRenderer( - bounds: window.bounds, + bounds: targetView.bounds, format: format ) let image = renderer.image { _ in - window.drawHierarchy( - in: window.bounds, + targetView.drawHierarchy( + in: targetView.bounds, afterScreenUpdates: true ) } @@ -229,10 +327,23 @@ private func installPreview(in scene: UIWindowScene) { throw PreviewRuntimeError.windowUnavailable } - let preview = try resolvePreview() - window.rootViewController = UIHostingController( - rootView: InjectedPreviewRoot(preview: preview) + 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) @@ -243,11 +354,15 @@ private func installPreview(in scene: UIWindowScene) { @available(iOS 17.0, *) @MainActor private func emitFailure(_ error: Error) { - emitEvent([ + var event: [String: Any] = [ "protocolVersion": 1, "type": "capture-failed", "message": error.localizedDescription, - ]) + ] + if capturedPreviewBody != nil { + event["name"] = capturedPreviewName + } + emitEvent(event) } @available(iOS 17.0, *) @@ -306,6 +421,9 @@ private enum PreviewRuntimeError: LocalizedError { } #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() { diff --git a/clients/swift/scripts/run-preview-e2e.js b/clients/swift/scripts/run-preview-e2e.js index 97bdd7ff..a2b38c91 100644 --- a/clients/swift/scripts/run-preview-e2e.js +++ b/clients/swift/scripts/run-preview-e2e.js @@ -26,6 +26,7 @@ try { assert.deepEqual(manifest.previews.map(preview => preview.name).sort(), [ 'Card / Dark', + 'Fixed Layout', 'Stateful Counter', ]); assert.ok( @@ -37,11 +38,38 @@ try { ) ); 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(preview => preview.sha256), - manifest.previews.map(preview => preview.sha256) + 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); @@ -52,11 +80,11 @@ try { assert.equal( JSON.parse(await readFile(join(outputPath, 'manifest.json'), 'utf8')) .previews.length, - 2 + 3 ); process.stdout.write( - `Verified ${manifest.previews.length} repeatable stock #Preview screenshots through the linked runtime and safe output replacement\n` + `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 index 1112a6a9..5464629c 100644 --- a/clients/swift/src/index.js +++ b/clients/swift/src/index.js @@ -19,6 +19,18 @@ export function resolvePreviewOptions(options, config) { }; } +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'), @@ -71,6 +83,7 @@ export async function run(container, options = {}, context = {}) { container, ...previewOptions, onProgress: message => output.info(message), + onFailure: message => output.warn(message), }); let upload; @@ -124,7 +137,7 @@ export async function run(container, options = {}, context = {}) { await testRunner.finalizeBuild( buildId, false, - true, + manifest.failures.length === 0, Date.now() - startTime ); upload = { @@ -166,6 +179,7 @@ export async function run(container, options = {}, context = {}) { } } + assertCompleteCapture(manifest); return manifest; } catch (error) { if (testRunner && buildId && !finalizationAttempted) { diff --git a/clients/swift/src/preview-runner.js b/clients/swift/src/preview-runner.js index 5d56e80d..a6495409 100644 --- a/clients/swift/src/preview-runner.js +++ b/clients/swift/src/preview-runner.js @@ -230,11 +230,11 @@ function runCommand(executable, args, options = {}) { child.stderr.on('data', chunk => stderr.push(chunk)); child.once('error', error => { if (error.name === 'AbortError') { - rejectPromise( - new Error( - `${basename(executable)} timed out after ${options.timeoutMs}ms` - ) + let timeoutError = new Error( + `${basename(executable)} timed out after ${options.timeoutMs}ms` ); + timeoutError.code = 'ETIMEDOUT'; + rejectPromise(timeoutError); return; } rejectPromise(error); @@ -605,33 +605,76 @@ async function captureRegistry({ let runtimePath = join(containerPath, 'Documents', runtimeFilename); await rm(runtimePath, { force: true }); - let 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, - }, + 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 ?? 'The app exited without capture completion'; - throw new Error(`Preview ${index + 1} failed: ${reason}`); + 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`; @@ -641,12 +684,14 @@ async function captureRegistry({ let metadata = readPngMetadata(buffer); return { - id: createHash('sha256').update(registryType).digest('hex').slice(0, 16), - name: resolved.name, - registryType, - viewType: resolved.viewType, - file: filename, - ...metadata, + preview: { + id: createHash('sha256').update(registryType).digest('hex').slice(0, 16), + name: resolved.name, + registryType, + viewType: resolved.viewType, + file: filename, + ...metadata, + }, }; } @@ -658,6 +703,7 @@ export async function runPreviewCapture({ outputPath: outputInput, captureTimeout = 30_000, onProgress = () => {}, + onFailure = () => {}, }) { let temporaryPath; let stagingPath; @@ -714,8 +760,9 @@ export async function runPreviewCapture({ ]); let dataContainerPath = containerResult.stdout.trim(); let previews = []; + let failures = []; for (let [index, registryType] of registryTypes.entries()) { - let preview = await captureRegistry({ + let capture = await captureRegistry({ registryType, index, device: resolvedDevice, @@ -724,6 +771,16 @@ export async function runPreviewCapture({ 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}`); } @@ -738,6 +795,7 @@ export async function runPreviewCapture({ configuration, outputPath, previews, + failures, }; await writeFile( join(stagingPath, 'manifest.json'), diff --git a/clients/swift/tests-js/index.test.js b/clients/swift/tests-js/index.test.js index 95caee3c..0c2448d6 100644 --- a/clients/swift/tests-js/index.test.js +++ b/clients/swift/tests-js/index.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { resolvePreviewOptions } from '../src/index.js'; +import { assertCompleteCapture, resolvePreviewOptions } from '../src/index.js'; describe('Swift preview CLI options', () => { it('uses configured defaults when command options are omitted', () => { @@ -47,3 +47,25 @@ describe('Swift preview CLI options', () => { 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/upload.test.js b/clients/swift/tests-js/upload.test.js index 6ba50fb8..06cb96f4 100644 --- a/clients/swift/tests-js/upload.test.js +++ b/clients/swift/tests-js/upload.test.js @@ -225,8 +225,10 @@ describe('Swift preview uploads', () => { 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.properties.threshold, 2.5); - assert.equal(requests[0].body.properties.minClusterSize, 3); + 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 () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2cdf0fd..846dc651 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,7 +306,7 @@ importers: devDependencies: '@biomejs/biome': specifier: ^2.5.10 - version: 2.5.10 + version: 2.5.11 '@vizzly-testing/cli': specifier: workspace:* version: link:../.. From f45f5da8b2e356e1738879a77f1c9a6f356a57eb Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Fri, 11 Sep 2026 00:08:44 -0500 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=A6=20Pin=20the=20compatible=20C?= =?UTF-8?q?LI=20beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview plugin uses the isolated screenshot service introduced in this branch. Require and document the matching CLI beta so a fresh PitStop install cannot silently select the older public CLI. --- README.md | 2 +- clients/swift/CHANGELOG.md | 2 ++ clients/swift/PREVIEWS.md | 2 +- clients/swift/README.md | 2 +- clients/swift/package.json | 2 +- clients/swift/tests-js/plugin.test.js | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d7d39585..e415a670 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ 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/swift@beta +pnpm add --save-dev @vizzly-testing/cli@beta @vizzly-testing/swift@beta pnpm exec vizzly previews ``` diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index ca8a1442..676ed5e6 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 uploaded before an incomplete capture exits with a failure. - Added `VizzlyPreviewRuntime.isCapturing` so apps can skip unsafe or unwanted startup services during preview launches. +- Added an explicit CLI peer version for the isolated screenshot service used + by preview uploads. ### Fixed diff --git a/clients/swift/PREVIEWS.md b/clients/swift/PREVIEWS.md index d58446d8..e3536ac5 100644 --- a/clients/swift/PREVIEWS.md +++ b/clients/swift/PREVIEWS.md @@ -22,7 +22,7 @@ in the capture manifest. Add the CLI and Swift plugin to the iOS project: ```bash -pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta +pnpm add --save-dev @vizzly-testing/cli@beta @vizzly-testing/swift@beta ``` Then add this repository as a Swift Package dependency in Xcode: diff --git a/clients/swift/README.md b/clients/swift/README.md index 5323e488..87aaa24d 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -15,7 +15,7 @@ You can use either one or both. Install the CLI and preview plugin in your iOS project: ```bash -pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta +pnpm add --save-dev @vizzly-testing/cli@beta @vizzly-testing/swift@beta ``` Add this repository as a Swift Package dependency, then add the dynamic diff --git a/clients/swift/package.json b/clients/swift/package.json index ded9ba17..f0def494 100644 --- a/clients/swift/package.json +++ b/clients/swift/package.json @@ -45,7 +45,7 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.35.3-beta.3" + "@vizzly-testing/cli": ">=0.36.1-beta.0" }, "publishConfig": { "access": "public", diff --git a/clients/swift/tests-js/plugin.test.js b/clients/swift/tests-js/plugin.test.js index d965dcc5..aaa67c9c 100644 --- a/clients/swift/tests-js/plugin.test.js +++ b/clients/swift/tests-js/plugin.test.js @@ -12,7 +12,7 @@ describe('Swift preview plugin package', () => { assert.ok(!packageJson.files.includes('Package.swift')); assert.equal( packageJson.peerDependencies['@vizzly-testing/cli'], - '>=0.35.3-beta.3' + '>=0.36.1-beta.0' ); }); From b11b6a12461a1e817279a4b828fb2c239e96f4f3 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Fri, 11 Sep 2026 00:43:25 -0500 Subject: [PATCH 10/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Reuse=20the=20existi?= =?UTF-8?q?ng=20CLI=20screenshot=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the Swift-only screenshot service and use the CLI's established public client for local and cloud uploads. This keeps the plugin compatible with the published 0.36.0 CLI. --- README.md | 2 +- clients/swift/CHANGELOG.md | 2 -- clients/swift/PREVIEWS.md | 2 +- clients/swift/README.md | 2 +- clients/swift/package.json | 2 +- clients/swift/src/index.js | 11 +++++-- clients/swift/src/upload.js | 32 ++++++++++++------- clients/swift/tests-js/plugin.test.js | 2 +- clients/swift/tests-js/upload.test.js | 46 ++++++++++----------------- src/client/index.js | 31 ++++++------------ src/plugin-api.js | 18 ----------- src/types/client.d.ts | 22 ------------- test-d/client.test-d.ts | 23 ++------------ tests/unit/plugin-api.test.js | 11 ------- 14 files changed, 62 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index e415a670..5c8231b4 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ 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@beta @vizzly-testing/swift@beta +pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta pnpm exec vizzly previews ``` diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 676ed5e6..ca8a1442 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -35,8 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 uploaded before an incomplete capture exits with a failure. - Added `VizzlyPreviewRuntime.isCapturing` so apps can skip unsafe or unwanted startup services during preview launches. -- Added an explicit CLI peer version for the isolated screenshot service used - by preview uploads. ### Fixed diff --git a/clients/swift/PREVIEWS.md b/clients/swift/PREVIEWS.md index e3536ac5..d58446d8 100644 --- a/clients/swift/PREVIEWS.md +++ b/clients/swift/PREVIEWS.md @@ -22,7 +22,7 @@ in the capture manifest. Add the CLI and Swift plugin to the iOS project: ```bash -pnpm add --save-dev @vizzly-testing/cli@beta @vizzly-testing/swift@beta +pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta ``` Then add this repository as a Swift Package dependency in Xcode: diff --git a/clients/swift/README.md b/clients/swift/README.md index 87aaa24d..5323e488 100644 --- a/clients/swift/README.md +++ b/clients/swift/README.md @@ -15,7 +15,7 @@ You can use either one or both. Install the CLI and preview plugin in your iOS project: ```bash -pnpm add --save-dev @vizzly-testing/cli@beta @vizzly-testing/swift@beta +pnpm add --save-dev @vizzly-testing/cli @vizzly-testing/swift@beta ``` Add this repository as a Swift Package dependency, then add the dynamic diff --git a/clients/swift/package.json b/clients/swift/package.json index f0def494..236f48b2 100644 --- a/clients/swift/package.json +++ b/clients/swift/package.json @@ -45,7 +45,7 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.36.1-beta.0" + "@vizzly-testing/cli": ">=0.36.0" }, "publishConfig": { "access": "public", diff --git a/clients/swift/src/index.js b/clients/swift/src/index.js index 5464629c..6d096c6e 100644 --- a/clients/swift/src/index.js +++ b/clients/swift/src/index.js @@ -41,7 +41,6 @@ async function saveManifest(manifest) { function requireCloudServices(services) { let requiredMethods = [ services?.git?.detect, - services?.screenshots?.createClient, services?.testRunner?.once, services?.testRunner?.createBuild, services?.testRunner?.finalizeBuild, @@ -67,6 +66,7 @@ export async function run(container, options = {}, context = {}) { 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; @@ -75,6 +75,11 @@ export async function run(container, options = {}, context = {}) { 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' @@ -101,7 +106,7 @@ export async function run(container, options = {}, context = {}) { let result = await uploadCapturedPreviews({ comparison: vizzlyConfig.comparison, manifest, - screenshots: services?.screenshots, + screenshotClient: await resolveScreenshotClient(), serverUrl: tddServerUrl, }); upload = { @@ -130,7 +135,7 @@ export async function run(container, options = {}, context = {}) { buildId, comparison: vizzlyConfig.comparison, manifest, - screenshots: services.screenshots, + screenshotClient: await resolveScreenshotClient(), serverUrl: `http://localhost:${runOptions.port}`, }); finalizationAttempted = true; diff --git a/clients/swift/src/upload.js b/clients/swift/src/upload.js index e62ef570..cdc0bd6b 100644 --- a/clients/swift/src/upload.js +++ b/clients/swift/src/upload.js @@ -176,34 +176,44 @@ export async function uploadCapturedPreviews({ buildId, comparison = {}, manifest, - screenshots, + screenshotClient, serverUrl, }) { - if (!screenshots?.createClient) { + let requiredMethods = [ + screenshotClient?.configure, + screenshotClient?.vizzlyFlush, + screenshotClient?.vizzlyScreenshot, + ]; + if (requiredMethods.some(method => typeof method !== 'function')) { throw new Error( - 'This Vizzly CLI does not provide screenshot uploads to plugins. Upgrade @vizzly-testing/cli.' + 'This @vizzly-testing/cli installation does not provide screenshot uploads' ); } - let client = screenshots.createClient({ + screenshotClient.configure({ + enabled: true, failOnDiff: shouldFailOnDiff(), serverUrl, }); let records = buildPreviewUploadRecords(manifest); for (let record of records) { - let result = await client.screenshot(record.name, record.filePath, { - buildId, - minClusterSize: comparison.minClusterSize, - properties: record.properties, - threshold: comparison.threshold, - }); + 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 client.flush(); + let flush = await screenshotClient.vizzlyFlush(); if (!flush && buildId) { throw new Error('Vizzly did not finish processing the preview screenshots'); } diff --git a/clients/swift/tests-js/plugin.test.js b/clients/swift/tests-js/plugin.test.js index aaa67c9c..16f1c55c 100644 --- a/clients/swift/tests-js/plugin.test.js +++ b/clients/swift/tests-js/plugin.test.js @@ -12,7 +12,7 @@ describe('Swift preview plugin package', () => { assert.ok(!packageJson.files.includes('Package.swift')); assert.equal( packageJson.peerDependencies['@vizzly-testing/cli'], - '>=0.36.1-beta.0' + '>=0.36.0' ); }); diff --git a/clients/swift/tests-js/upload.test.js b/clients/swift/tests-js/upload.test.js index 06cb96f4..a90a0ad1 100644 --- a/clients/swift/tests-js/upload.test.js +++ b/clients/swift/tests-js/upload.test.js @@ -4,7 +4,11 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; -import { createPluginServices } from '../../../src/plugin-api.js'; +import { + configure, + vizzlyFlush, + vizzlyScreenshot, +} from '../../../src/client/index.js'; import { buildCloudRunOptions, buildPreviewUploadRecords, @@ -58,22 +62,6 @@ function previewManifest(outputPath) { }; } -function pluginServices() { - return createPluginServices({ - testRunner: { - once() {}, - on() {}, - off() {}, - createBuild() {}, - finalizeBuild() {}, - }, - serverManager: { - start() {}, - stop() {}, - }, - }); -} - async function startServer(handler) { let server = createServer(handler); await new Promise(resolvePromise => server.listen(0, resolvePromise)); @@ -186,7 +174,7 @@ describe('Swift preview uploads', () => { assert.equal(await findLocalTddServer([nested]), null); }); - it('uploads every rendered PNG and flushes through the plugin service', async () => { + it('uploads every rendered PNG through the public CLI client', async () => { let requests = []; let serverUrl = await startServer((request, response) => { let chunks = []; @@ -212,7 +200,7 @@ describe('Swift preview uploads', () => { buildId: 'build-123', comparison: { minClusterSize: 3, threshold: 2.5 }, manifest, - screenshots: pluginServices().screenshots, + screenshotClient: { configure, vizzlyFlush, vizzlyScreenshot }, serverUrl, }); @@ -233,17 +221,15 @@ describe('Swift preview uploads', () => { it('honors both supported fail-on-diff environment values', async () => { let receivedValues = []; - let screenshots = { - createClient(options) { + let screenshotClient = { + configure(options) { receivedValues.push(options.failOnDiff); - return { - async flush() { - return { success: true }; - }, - async screenshot() { - return { success: true }; - }, - }; + }, + async vizzlyFlush() { + return { success: true }; + }, + async vizzlyScreenshot() { + return { success: true }; }, }; let originalValue = process.env.VIZZLY_FAIL_ON_DIFF; @@ -253,7 +239,7 @@ describe('Swift preview uploads', () => { process.env.VIZZLY_FAIL_ON_DIFF = value; await uploadCapturedPreviews({ manifest: previewManifest('/tmp/previews'), - screenshots, + screenshotClient, serverUrl: 'http://localhost:47392', }); } diff --git a/src/client/index.js b/src/client/index.js index 75cb6a6c..ac920f5b 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -138,10 +138,8 @@ function getClient() { // If we have a server URL, create the client (regardless of initial enabled state) if (serverUrl) { currentServerUrl = serverUrl; - currentClient = createScreenshotClient({ - disableOnFailure: true, + currentClient = createSimpleClient(serverUrl, { failOnDiff: currentFailOnDiff, - serverUrl, }); } } @@ -209,14 +207,11 @@ function httpPost(url, body, timeoutMs) { } /** - * Create a screenshot client connected to one Vizzly server. + * Create a simple HTTP client for screenshots + * @private */ -export function createScreenshotClient(options = {}) { - let { disableOnFailure = false, failOnDiff = false, serverUrl } = options; - - if (!serverUrl) { - throw new Error('A Vizzly screenshot server URL is required'); - } +function createSimpleClient(serverUrl, clientOptions = {}) { + let { failOnDiff = false } = clientOptions; return { async screenshot(name, imageBuffer, options = {}) { @@ -303,9 +298,7 @@ export function createScreenshotClient(options = {}) { `[vizzly] Screenshot timed out for "${name}" after ${requestTimeout / 1000}s` ); } - if (disableOnFailure) { - disableVizzly(); - } + disableVizzly(); return null; } @@ -335,9 +328,7 @@ export function createScreenshotClient(options = {}) { } // Disable the SDK after first failure to prevent spam - if (disableOnFailure) { - disableVizzly(); - } + disableVizzly(); // Don't throw - just return silently to not break tests return null; @@ -460,17 +451,13 @@ export function configure(config = {}) { if ('serverUrl' in config) { currentServerUrl = config.serverUrl || null; currentClient = config.serverUrl - ? createScreenshotClient({ - disableOnFailure: true, + ? createSimpleClient(config.serverUrl, { failOnDiff: currentFailOnDiff, - serverUrl: config.serverUrl, }) : null; } else if ('failOnDiff' in config && currentClient && currentServerUrl) { - currentClient = createScreenshotClient({ - disableOnFailure: true, + currentClient = createSimpleClient(currentServerUrl, { failOnDiff: currentFailOnDiff, - serverUrl: currentServerUrl, }); } diff --git a/src/plugin-api.js b/src/plugin-api.js index f6704143..8e46243c 100644 --- a/src/plugin-api.js +++ b/src/plugin-api.js @@ -9,7 +9,6 @@ * exposed to plugins to prevent coupling to implementation details. */ -import { createScreenshotClient } from './client/index.js'; import { detectBranch, detectCommit, @@ -23,7 +22,6 @@ import { * * Only exposes: * - git: Git information detection (branch, commit, PR number, etc.) - * - screenshots: Screenshot delivery to a Vizzly server * - testRunner: Build lifecycle management (createBuild, finalizeBuild, events) * - serverManager: Screenshot server control (start, stop) * @@ -63,22 +61,6 @@ export function createPluginServices(services) { }, }), - screenshots: Object.freeze({ - /** - * Create an isolated screenshot client for a local TDD or cloud proxy - * server. Plugins pass build IDs per screenshot when cloud routing is - * required. - * - * @param {Object} options - Client options - * @param {string} options.serverUrl - Vizzly screenshot server URL - * @param {boolean} [options.failOnDiff] - Fail local TDD on visual diffs - * @returns {Object} Screenshot client with screenshot and flush methods - */ - createClient(options) { - return createScreenshotClient(options); - }, - }), - testRunner: Object.freeze({ // EventEmitter methods for build lifecycle events once: testRunner.once.bind(testRunner), diff --git a/src/types/client.d.ts b/src/types/client.d.ts index ca51affe..f0c2d581 100644 --- a/src/types/client.d.ts +++ b/src/types/client.d.ts @@ -48,28 +48,6 @@ export interface ScreenshotResult { [key: string]: unknown; } -export interface ScreenshotClient { - screenshot( - name: string, - imageBuffer: Buffer | string, - options?: { - properties?: Record; - threshold?: number; - minClusterSize?: number; - fullPage?: boolean; - buildId?: string; - requestTimeout?: number; - } - ): Promise; - flush(): Promise; -} - -/** Create an isolated client connected to one screenshot server. */ -export function createScreenshotClient(options: { - serverUrl: string; - failOnDiff?: boolean; -}): ScreenshotClient; - /** * Take a screenshot for visual regression testing * diff --git a/test-d/client.test-d.ts b/test-d/client.test-d.ts index c827f945..b671bfb0 100644 --- a/test-d/client.test-d.ts +++ b/test-d/client.test-d.ts @@ -2,15 +2,10 @@ * Type tests for @vizzly-testing/cli/client */ import { expectError, expectType } from 'tsd'; -import type { - FlushResult, - ScreenshotClient, - ScreenshotResult, -} from '../src/types/client'; +import type { ScreenshotResult } from '../src/types/client'; import { autoDiscoverTddServer, configure, - createScreenshotClient, getVizzlyInfo, isVizzlyReady, LOG_LEVELS, @@ -26,20 +21,6 @@ let screenshotResult: ScreenshotResult = { }; expectType(screenshotResult); -let isolatedClient = createScreenshotClient({ - serverUrl: 'http://localhost:47392', - failOnDiff: true, -}); -expectType(isolatedClient); -expectType>( - isolatedClient.screenshot('preview', './preview.png', { - buildId: 'build-123', - properties: { platform: 'iOS' }, - }) -); -expectType>(isolatedClient.flush()); -expectError(createScreenshotClient({})); - // ============================================================================ // vizzlyScreenshot // ============================================================================ @@ -93,6 +74,8 @@ expectError( // ============================================================================ // Should return Promise +import type { FlushResult } from '../src/types/client'; + expectType>(vizzlyFlush()); let flushResult: FlushResult = { success: true, diff --git a/tests/unit/plugin-api.test.js b/tests/unit/plugin-api.test.js index 445b3b98..e2f4a6b6 100644 --- a/tests/unit/plugin-api.test.js +++ b/tests/unit/plugin-api.test.js @@ -54,21 +54,10 @@ describe('Plugin API', () => { assert.ok(Object.isFrozen(services), 'services should be frozen'); assert.ok(services.git, 'should have git property'); - assert.ok(services.screenshots, 'should have screenshots property'); assert.ok(services.testRunner, 'should have testRunner property'); assert.ok(services.serverManager, 'should have serverManager property'); }); - it('exposes a screenshot client factory', () => { - let services = createPluginServices(mockServices); - let client = services.screenshots.createClient({ - serverUrl: 'http://localhost:47392', - }); - - assert.strictEqual(typeof client.screenshot, 'function'); - assert.strictEqual(typeof client.flush, 'function'); - }); - it('exposes git.detect as a function', () => { let services = createPluginServices(mockServices);