Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for snapshotting Swift UI scenes. #110

Merged
merged 27 commits into from
Jun 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4aca722
Updated ReusableWorkflows to v20.
grigorye Jun 20, 2023
661a2d3
Extracted Snapshotting/NSWindow+Snapshotting.
grigorye Jun 18, 2023
7ebfea6
Made AppStateSample Codable; added AppStateSample.sampleName.
grigorye Jun 18, 2023
0919a2e
Made ContetView.Tab Codable.
grigorye Jun 18, 2023
32ba2c7
Extracted Paths+SnapshotTesting.swift.
grigorye Jun 18, 2023
1f93abe
Added support for snapshotting driven from UI tests.
grigorye Jun 18, 2023
0510e64
Added TMBuddyUITestSnapshotsHost and TMBuddyUITestSnapshots.
grigorye Jun 18, 2023
349324a
Tweaked for CFBundleDisplayName.
grigorye Jun 18, 2023
6a515ad
Added TMBuddyAllSnapshots.
grigorye Jun 18, 2023
b720f4b
Employed TMBuddyAllSnapshots.
grigorye Jun 18, 2023
9cbf685
Updated ReusableWorkflows.
grigorye Jun 19, 2023
1b434a7
Updated for ui-snapshot-tests-scheme.
grigorye Jun 20, 2023
e3a1200
Added GHAShortcuts/ListSnapshots.
grigorye Jun 20, 2023
d2a1d00
Added GHAShortcuts/ListSnapshots.
grigorye Jun 20, 2023
1038183
Switched to +UITestSnapshots.
grigorye Jun 20, 2023
ec7d43e
Added support for relocated SRCROOT.
grigorye Jun 24, 2023
3986837
Added xcbeautify.
grigorye Jun 24, 2023
25f929b
Dropped xcbeautify.
grigorye Jun 24, 2023
f3c73ec
Updated ReusableWorkflows.
grigorye Jun 20, 2023
b9364cd
Updated snapshots via TMBuddySnapshots.
grigorye Jun 24, 2023
3257f34
Updated snapshots via TMBuddyUITestSnapshots.
grigorye Jun 24, 2023
9475a7c
Updated links to snapshots.
grigorye Jun 24, 2023
2086d7b
Fixed environment for UITestSnapshots.
grigorye Jun 24, 2023
eacc7a4
Fixed modification date not updated for UITestSnapshots.
grigorye Jun 25, 2023
32e66f6
Fixed project exclusions to account +UITestSnapshots.
grigorye Jun 25, 2023
42985bc
Updated ReusableWorkflows.
grigorye Jun 25, 2023
f359d19
Updated ReusableWorkflows.
grigorye Jun 25, 2023
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
5 changes: 3 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,19 @@ jobs:
name: 'Tests'
needs: [paths-filter]
if: ${{ !inputs.skip-tests && github.event.pull_request.draft != true && needs.paths-filter.outputs.should-test != 'false' }}
uses: grigorye/ReusableWorkflows/.github/workflows/tests-generic.yml@v19
uses: grigorye/ReusableWorkflows/.github/workflows/tests-generic.yml@v20
secrets: inherit
with:
runs-on: 'macos-12'
unit-tests-scheme: 'TMBuddyTests'
snapshot-tests-scheme: 'TMBuddySnapshots'
ui-snapshot-tests-scheme: 'TMBuddyUITestSnapshots'

build-app:
name: 'App'
needs: [paths-filter]
if: ${{ !inputs.skip-build-app && github.event.pull_request.draft != true && needs.paths-filter.outputs.should-build != 'false' }}
uses: grigorye/ReusableWorkflows/.github/workflows/build-app-generic.yml@v19
uses: grigorye/ReusableWorkflows/.github/workflows/build-app-generic.yml@v20
with:
macos-app-scheme: 'TMBuddy'
build-configs: '[\"app-store\", \"developer-id\"]'
Expand Down
17 changes: 17 additions & 0 deletions GHAShortcuts/ListSnapshots
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#! /bin/bash

[ "${RUNNER_DEBUG:-}" == "1" ] && set -x

set -euo pipefail

case "$TESTS_SCHEME" in
TMBuddySnapshots)
find Targets -path '**+Snapshots/**/*.png'
;;
TMBuddyUITestSnapshots)
find Targets -path '**+UITestSnapshots/**/*.png'
;;
*)
exit 1
;;
esac
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ See and manipulate Time Machine exclusions, right in Finder.

2. Launch the app and follow the checklist, making sure all the red lights:

<img src="Targets/TMBuddy/Sources/Content/Standalone/MainWindow+Snapshots/macOS-13.3/test.AppStateSample-allGreen-false-tab-TMBuddySnapshots-ContentView-Tab-setup.@2x.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />
<img src="Targets/TMBuddy/Sources/Content/Standalone/MainWindow+Snapshots/macOS-13.3/test.AppStateSample-allGreen-false-tab-TMBuddySnapshots-ContentView-Tab-folders.@2x.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />
<img src="Targets/TMBuddyUITestSnapshots/MainWindow+UITestSnapshots/macOS-13.3/test.allRed_tab-setup.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />
<img src="Targets/TMBuddyUITestSnapshots/MainWindow+UITestSnapshots/macOS-13.3/test.allRed_tab-folders.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />

turned green:

<img src="Targets/TMBuddy/Sources/Content/Standalone/MainWindow+Snapshots/macOS-13.3/test.AppStateSample-allGreen-true-tab-TMBuddySnapshots-ContentView-Tab-setup.@2x.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />
<img src="Targets/TMBuddy/Sources/Content/Standalone/MainWindow+Snapshots/macOS-13.3/test.AppStateSample-allGreen-true-tab-TMBuddySnapshots-ContentView-Tab-folders.@2x.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />
<img src="Targets/TMBuddyUITestSnapshots/MainWindow+UITestSnapshots/macOS-13.3/test.allGreen_tab-setup.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />
<img src="Targets/TMBuddyUITestSnapshots/MainWindow+UITestSnapshots/macOS-13.3/test.allGreen_tab-folders.png" alt="Checklist-Red.png" width=75% style="zoom:50%;" />

When selecting folders for the application, typically you want to navigate to Computer and select all the disks for which you want to employ TMBuddy:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import AppKit
import XCTest

@MainActor
extension XCTestCase {

func snapshotFlakyBorderWindow(
window: NSWindow,
named name: String,
record: Bool,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line
) {
snapshotFlakyBorderWindow(
windowNumber: window.windowNumber,
named: name,
record: record,
file: file,
testName: testName,
line: line
)
}

func snapshotFlakyBorderWindow(
windowNumber: Int,
listOptions: CGWindowListOption = [],
named name: String?,
record: Bool,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line
) {
let windowNumbers = NSWindow.windowNumbers(listOptions: listOptions, windowNumber: windowNumber)

assert(!windowNumbers.isEmpty)

let failures: [String] = windowNumbers.compactMap { windowNumber in
let window = NSApplication.shared.window(withWindowNumber: windowNumber)!

return try! verifySnapshot(
matching: NSWindow.snapshot(windowNumbers: [windowNumber], imageOptions: [.bestResolution, .boundsIgnoreFraming]),
as: .image(scaleFactor: window.backingScaleFactor),
named: name,
record: record,
file: file,
testName: testName + ".borderless",
line: line
)
}
if !failures.isEmpty {
XCTFail(failures.first!, file: file, line: line)
}

let window = NSApplication.shared.window(withWindowNumber: windowNumber)!
assertSnapshot(
matching: try NSWindow.snapshot(windowNumbers: windowNumbers, imageOptions: [.bestResolution]),
as: .image(scaleFactor: window.backingScaleFactor, ignoreDiffs: failures.isEmpty),
named: name,
record: record,
file: file,
testName: testName,
line: line
)
}
}
17 changes: 17 additions & 0 deletions Targets/OtherTesting/SnapshotTesting/Paths+SnapshotTesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

func snapshotDirectory(forFile file: String) -> String {
let file = adjustForSrcRootRelocation(file: file)
let fileUrl = URL(fileURLWithPath: file, isDirectory: false)
let snapshotDirectoryUrl = fileUrl
.deletingPathExtension()
.appendingPathComponent(environmentSpecificRelativeSnapshotsPath())
return snapshotDirectoryUrl.path
}

func environmentSpecificRelativeSnapshotsPath() -> String {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
return [
"macOS-\(osVersion.majorVersion).\(osVersion.minorVersion)"
].joined(separator: "/")
}
41 changes: 41 additions & 0 deletions Targets/OtherTesting/SnapshotTesting/Paths+SrcRootRelocation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

func adjustForSrcRootRelocation(file: String) -> String {
let oldSrcRoot = ProcessInfo().environment["OLD_SRCROOT"]
let newSrcRoot = ProcessInfo().environment["SRCROOT"]

guard let newSrcRoot, let oldSrcRoot else {
assert(oldSrcRoot == nil, "OLD_SRCROOT is set, but SRCROOT is not.")
return file
}

return adjust(file: file, forSrcRoot: oldSrcRoot, changedTo: newSrcRoot)
}

private func adjust(file: String, forSrcRoot oldSrcRoot: String, changedTo newSrcRoot: String) -> String {
// SRCROOT may be somewhere below the root of the actual tree: it's fine, as long as its relative location remains the same.

let oldBase = oldSrcRoot.commonPrefix(with: file)
let oldSuffix = oldSrcRoot[oldBase.endIndex...]
let newBase = newSrcRoot.dropLast(oldSuffix.count)

assert(file.hasPrefix(oldBase))

return file.replacingOccurrences(of: oldBase, with: newBase, options: .anchored)
}

import XCTest

class Path_SrcRootRelocation_Tests: XCTestCase {

func testAdjustForChangedSrcRoot() {
XCTAssertEqual(
adjust(
file: "/Users/user-1/Foo/Bar/Baz/Source.swift",
forSrcRoot: "/Users/user-1/Foo/Bar/Project",
changedTo: "/Users/user-2/Foo/Bar/Project"
),
"/Users/user-2/Foo/Bar/Baz/Source.swift"
)
}
}
16 changes: 0 additions & 16 deletions Targets/OtherTesting/SnapshotTesting/Samples+SnapshotTesting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ extension Snapshotting {
}
}


func suffixForScaleFactor(_ scaleFactor: CGFloat) -> String? {
guard scaleFactor != 1.0 else {
return nil
Expand All @@ -105,18 +104,3 @@ func suffixForScaleFactor(_ scaleFactor: CGFloat) -> String? {
return "@\(scaleFactor)x"
}
}

func snapshotDirectory(forFile file: String) -> String {
let fileUrl = URL(fileURLWithPath: file, isDirectory: false)
let snapshotDirectoryUrl = fileUrl
.deletingPathExtension()
.appendingPathComponent(environmentSpecificRelativeSnapshotsPath())
return snapshotDirectoryUrl.path
}

func environmentSpecificRelativeSnapshotsPath() -> String {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
return [
"macOS-\(osVersion.majorVersion).\(osVersion.minorVersion)"
].joined(separator: "/")
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import AppKit
import XCTest

/// Converts given window numbers into CFArray of CGWindowID's.
func windowArray(_ windowNumbers: [Int]) -> CFArray {
Expand Down Expand Up @@ -48,70 +47,6 @@ enum WindowSnapshotError: Swift.Error {
case timedOut(attempts: Int, timeout: TimeInterval)
}

@MainActor
extension XCTestCase {

func snapshotFlakyBorderWindow(
window: NSWindow,
named name: String,
record: Bool,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line
) {
snapshotFlakyBorderWindow(
windowNumber: window.windowNumber,
named: name,
record: record,
file: file,
testName: testName,
line: line
)
}

func snapshotFlakyBorderWindow(
windowNumber: Int,
listOptions: CGWindowListOption = [],
named name: String?,
record: Bool,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line
) {
let windowNumbers = NSWindow.windowNumbers(listOptions: listOptions, windowNumber: windowNumber)

assert(!windowNumbers.isEmpty)

let failures: [String] = windowNumbers.compactMap { windowNumber in
let window = NSApplication.shared.window(withWindowNumber: windowNumber)!

return try! verifySnapshot(
matching: NSWindow.snapshot(windowNumbers: [windowNumber], imageOptions: [.bestResolution, .boundsIgnoreFraming]),
as: .image(scaleFactor: window.backingScaleFactor),
named: name,
record: record,
file: file,
testName: testName + ".borderless",
line: line
)
}
if !failures.isEmpty {
XCTFail(failures.first!, file: file, line: line)
}

let window = NSApplication.shared.window(withWindowNumber: windowNumber)!
assertSnapshot(
matching: try NSWindow.snapshot(windowNumbers: windowNumbers, imageOptions: [.bestResolution]),
as: .image(scaleFactor: window.backingScaleFactor, ignoreDiffs: failures.isEmpty),
named: name,
record: record,
file: file,
testName: testName,
line: line
)
}
}

extension NSWindow {

static func windowNumbers(listOptions: CGWindowListOption = [], windowNumber: Int) -> [Int] {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import AppKit

@MainActor
func snapshotFlakyBorderWindow(window: NSWindow, snapshotBaseURL: URL) {
let borderlessSnapshotURL = snapshotBaseURL.appendingPathExtension("borderless.png")
let shadowSnapshotURL = snapshotBaseURL.appendingPathExtension("png")
defer {
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: borderlessSnapshotURL.path)
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: shadowSnapshotURL.path)
}
// Snapshot shadowless, and *bail out* unless it's changed.
do {
let snapshotURL = borderlessSnapshotURL
let snapshot = try! window.snapshot(options: [.bestResolution, .boundsIgnoreFraming])
let newBitmapRep = NSBitmapImageRep(data: snapshot.tiffRepresentation!)!.representation(using: .png, properties: [:])!
if let oldBitmapRep = try? Data(contentsOf: snapshotURL) {
guard oldBitmapRep != newBitmapRep else {
return // Avoid recording yet another (flaky) snapshot with shadow.
}
}
try! FileManager.default.createDirectory(at: snapshotURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try! newBitmapRep.write(to: snapshotURL)
}

// Take another snapshot variant, now with shadow.
do {
let snapshotURL = shadowSnapshotURL
let snapshot = try! window.snapshot(options: [.bestResolution])
let newBitmapRep = NSBitmapImageRep(data: snapshot.tiffRepresentation!)!.representation(using: .png, properties: [:])!
try! newBitmapRep.write(to: snapshotURL)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import SwiftUI
import AppKit

extension View {
func snapshottingUITestWindow() -> some View {
self
.background(SnapshottingView())
}
}

private struct SnapshottingView: NSViewRepresentable {

func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5 /* allow window to become key one */) {
snapshot(window: view.window)
}
return view
}

func updateNSView(_ nsView: NSView, context: Context) {
snapshot(window: nsView.window)
}

@MainActor
func snapshot(window: NSWindow?) {
guard let window = window else { return }
let snapshotBaseURL = URL(fileURLWithPath: UserDefaults.standard.string(forKey: "snapshotBasePath")!)
snapshotFlakyBorderWindow(window: window, snapshotBaseURL: snapshotBaseURL)
}
}
2 changes: 1 addition & 1 deletion Targets/TMBuddy/Sources/AppGlobals/DisplayNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ private let fileManager = FileManager.default

@MainActor var appName: String {
#if !GE_SNAPSHOT_TESTING
fileManager.displayName(atPath: Bundle.main.bundlePath)
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? fileManager.displayName(atPath: Bundle.main.bundlePath)
#else
"TMBuddy"
#endif
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import SwiftUI

struct AppStateSample: CaseIterable {
struct AppStateSample: CaseIterable, Codable {
var allGreen: Bool
var tab: ContentView.Tab

Expand All @@ -11,6 +11,13 @@ struct AppStateSample: CaseIterable {
}
}
}

var sampleName: String {
[
allGreen ? "allGreen" : "allRed",
"tab-\(tab.rawValue)"
].joined(separator: "_")
}
}

extension View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation

struct ContentView: View {

enum Tab: String {
enum Tab: String, Codable {
case setup
case folders
case legend
Expand Down
Loading