Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.

Expand Down
36 changes: 36 additions & 0 deletions Sources/SemanticsTesting/SemanticsTester+Snapshot.swift
Original file line number Diff line number Diff line change
@@ -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 `<name>.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
}
}
54 changes: 54 additions & 0 deletions Tests/SemanticsTestingTests/SemanticsTesterSnapshotTests.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading