From 70f90c24dbe97cbf853794f437f324ba274e7908 Mon Sep 17 00:00:00 2001 From: Yannick Pulver Date: Mon, 5 Oct 2026 19:27:16 +0200 Subject: [PATCH] Add snapshot() to write the hosted window as a PNG --- README.md | 9 +++- .../SemanticsTester+Snapshot.swift | 36 +++++++++++++ .../SemanticsTesterSnapshotTests.swift | 54 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 Sources/SemanticsTesting/SemanticsTester+Snapshot.swift create mode 100644 Tests/SemanticsTestingTests/SemanticsTesterSnapshotTests.swift diff --git a/README.md b/README.md index f11b0a5..b3d3e81 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ struct SemanticsTestHostApp: App { } ``` +Give the host a launch screen (`UILaunchScreen` as an empty dictionary in its `Info.plist`). +Without one iOS runs it letterboxed at 320×480 points on every device, and so do your tests. + If your app ships custom fonts, register them in the host's `Info.plist` under `UIAppFonts` too. Without them the host falls back to the system face, and a view that wraps or truncates in the app measures differently in the test. @@ -121,7 +124,7 @@ Queries return `SemanticsNode` values: `label`, `value`, `identifier`, `traits`, | Wait | `expect(_:)`, `expect(id:)`, `expect(containing:)`, `expectGone(_:)`, `wait(where:)` | `expectAsync`, `expectGoneAsync`, `waitAsync` | | Act | `tap(_:)`, `type(_:into:)`, `forceActivate(_:)`, `node.activate()`, `node.performCustomAction(named:)`, `node.increment()` / `decrement()` | `tapAsync(_:)` | | Time | `pump(_:)` | `settle(_:)` | -| Debug | `dump()`, `SemanticsWalker.dump(_:)` | | +| Debug | `dump()`, `SemanticsWalker.dump(_:)`, `snapshot(_:in:)` | | Waits scroll every scroll view down one step after a third of the timeout, so lazy rows below the fold materialize the way they would for a user. @@ -130,6 +133,10 @@ the fold materialize the way they would for a user. prefers buttons, and refuses disabled controls. `forceActivate` skips the enabled check, for negative tests only. +`snapshot("name")` writes the hosted window, plus menu and popover windows, as a PNG into +`SNAPSHOT_DIR` (`TEST_RUNNER_SNAPSHOT_DIR` via xcodebuild) or a temp folder. It is for looking +at a view, e.g. in a PR review. It compares nothing and does not include the system status bar. + Set `SEMANTICS_DEMO_PAUSE=1` (seconds; via `TEST_RUNNER_SEMANTICS_DEMO_PAUSE` when launching through xcodebuild) to slow interactions down enough to watch them on the simulator. diff --git a/Sources/SemanticsTesting/SemanticsTester+Snapshot.swift b/Sources/SemanticsTesting/SemanticsTester+Snapshot.swift new file mode 100644 index 0000000..fce2919 --- /dev/null +++ b/Sources/SemanticsTesting/SemanticsTester+Snapshot.swift @@ -0,0 +1,36 @@ +// +// SemanticsTester+Snapshot.swift +// Copyright © 2026 Apps with love AG. All rights reserved. +// + +import UIKit + +public extension SemanticsTester { + /// Writes the hosted window, plus any menu or popover window opened since, as `.png`. + /// Meant for looking at a view (e.g. in a PR review), not for comparing against a reference. + /// + /// The image covers the app's windows only: the system status bar is not part of it. + /// + /// - Parameter directory: target folder. Defaults to the `SNAPSHOT_DIR` environment variable + /// (`TEST_RUNNER_SNAPSHOT_DIR` when launching through xcodebuild), else a temp folder. + @discardableResult + func snapshot(_ name: String, in directory: URL? = nil) throws -> URL { + window.layoutIfNeeded() + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { context in + for root in searchRoots where !root.isHidden { + // A host-less window is not on screen, and drawHierarchy then draws nothing. + if !root.drawHierarchy(in: root.frame, afterScreenUpdates: true) { + root.layer.render(in: context.cgContext) + } + } + } + let folder = directory + ?? ProcessInfo.processInfo.environment["SNAPSHOT_DIR"].map { URL(fileURLWithPath: $0) } + ?? FileManager.default.temporaryDirectory.appendingPathComponent("semantics-snapshots") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let url = folder.appendingPathComponent("\(name).png") + try image.pngData()?.write(to: url) + print("SemanticsTester: snapshot written to \(url.path)") + return url + } +} diff --git a/Tests/SemanticsTestingTests/SemanticsTesterSnapshotTests.swift b/Tests/SemanticsTestingTests/SemanticsTesterSnapshotTests.swift new file mode 100644 index 0000000..635fe44 --- /dev/null +++ b/Tests/SemanticsTestingTests/SemanticsTesterSnapshotTests.swift @@ -0,0 +1,54 @@ +// +// SemanticsTesterSnapshotTests.swift +// Copyright © 2026 Apps with love AG. All rights reserved. +// + +import SemanticsTesting +import SwiftUI +import Testing +import UIKit + +@Suite(.serialized) +@MainActor +struct SemanticsTesterSnapshotTests { + private let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("SemanticsTesterSnapshotTests-\(UUID().uuidString)") + + @Test func writesAPNGOfTheWindowSize() async throws { + let tester = await SemanticsTester.make { Text("Hello") } + defer { tester.tearDown() } + try tester.expect("Hello") + + let url = try tester.snapshot("hello", in: folder) + + #expect(url.lastPathComponent == "hello.png") + // Loaded from disk the image has scale 1, so its size is in pixels. + let image = try #require(UIImage(contentsOfFile: url.path)?.cgImage) + let scale = tester.window.traitCollection.displayScale + #expect(CGFloat(image.width) == tester.window.bounds.width * scale) + #expect(CGFloat(image.height) == tester.window.bounds.height * scale) + } + + @Test func drawsTheHostedContent() async throws { + let tester = await SemanticsTester.make { Color(red: 1, green: 0, blue: 0).ignoresSafeArea() } + defer { tester.tearDown() } + + let url = try tester.snapshot("red", in: folder) + + let image = try #require(UIImage(contentsOfFile: url.path)?.cgImage) + let pixel = try #require(Self.centerPixel(of: image)) + #expect(pixel.red > 200 && pixel.green < 50 && pixel.blue < 50, "center pixel \(pixel)") + } + + private static func centerPixel(of image: CGImage) -> (red: UInt8, green: UInt8, blue: UInt8)? { + var data = [UInt8](repeating: 0, count: 4) + guard let context = CGContext( + data: &data, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue, + ) else { return nil } + let center = CGRect(x: -image.width / 2, y: -image.height / 2, width: image.width, height: image.height) + context.draw(image, in: center) + return (data[0], data[1], data[2]) + } +}