Repository: soundcloud/Axt Branch: master Commit: 23a8a92bd77a Files: 46 Total size: 68.1 KB Directory structure: gitextract_772q8wgy/ ├── .gitignore ├── Examples/ │ └── AxtExamples/ │ ├── AxtExamples/ │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── AxtExamplesApp.swift │ │ ├── ConfirmationAlertModifier.swift │ │ ├── ContentView.swift │ │ ├── CustomControls.swift │ │ ├── GestureView.swift │ │ ├── MoreButton.swift │ │ ├── NativeViews.swift │ │ ├── Preview Content/ │ │ │ └── Preview Assets.xcassets/ │ │ │ └── Contents.json │ │ └── TogglesView.swift │ ├── AxtExamples.xcodeproj/ │ │ ├── project.pbxproj │ │ └── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ └── IDEWorkspaceChecks.plist │ └── AxtExamplesTests/ │ ├── ConfirmationAlertModifierTests.swift │ ├── CustomControlsTests.swift │ ├── GestureViewTests.swift │ ├── MoreButtonTests.swift │ ├── NativeViewsTests.swift │ └── TogglesViewTests.swift ├── Package.swift ├── README.md └── Sources/ └── Axt/ ├── Axt.swift ├── AxtTest/ │ ├── Axt+description.swift │ ├── Axt+find.swift │ ├── AxtChildNode.swift │ ├── AxtElement.swift │ ├── AxtNode.swift │ ├── AxtTest.swift │ ├── Publisher+Compatibility.swift │ ├── Publisher+firstValue.swift │ └── printHierarchy.swift ├── AxtView.swift ├── Modifier.swift ├── Native/ │ ├── Button.swift │ ├── NavigationLink.swift │ ├── Text.swift │ ├── TextField.swift │ └── Toggle.swift ├── NativeView.swift ├── View+testData.swift ├── View+testId.swift ├── dig.swift └── hostAxtSheet.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store /.build /Packages /*.xcodeproj xcuserdata/ DerivedData/ .swiftpm/config/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Examples/AxtExamples/AxtExamples/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/AxtExamples/AxtExamples/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/AxtExamples/AxtExamples/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/AxtExamples/AxtExamples/AxtExamplesApp.swift ================================================ import SwiftUI @main struct AxtExamplesApp: App { var body: some Scene { WindowGroup { ContentView() } } } ================================================ FILE: Examples/AxtExamples/AxtExamples/ConfirmationAlertModifier.swift ================================================ import SwiftUI struct ConfirmationAlertModifier: ViewModifier { @Binding var isPresented: Bool let message: String let action1: () -> Void let action2: () -> Void func body(content: Content) -> some View { content.alert(isPresented: $isPresented) { Alert( title: Text(message), primaryButton: .default(Text("1"), action: action1), secondaryButton: .default(Text("2"), action: action2)) } .testId(insert: "button_1", when: isPresented, label: "1", action: action1) .testId(insert: "button_2", when: isPresented, label: "2", action: action2) } } ================================================ FILE: Examples/AxtExamples/AxtExamples/ContentView.swift ================================================ import SwiftUI struct ContentView: View { var body: some View { Text("Hello, world!") .padding() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } ================================================ FILE: Examples/AxtExamples/AxtExamples/CustomControls.swift ================================================ import SwiftUI struct CustomControls: View { @State var counter = 0 var body: some View { MyButton() { counter += 1 } .testId("my_button") .testId(insert: "counter", value: counter) } } struct MyButton: View { let action: () -> Void var body: some View { Button("Tap me") { action() } .testData(action: action) } } ================================================ FILE: Examples/AxtExamples/AxtExamples/GestureView.swift ================================================ import SwiftUI struct GestureView: View { @State private var dragY: CGFloat = 0 var body: some View { knob .testId("knob") .frame(width: 50, height: 50) .offset(x: 0, y: dragY) .gesture(gesture) .testId(insert: "drag", value: dragY, setValue: { dragY = $0 as? CGFloat ?? 0 }) } @ViewBuilder private var knob: some View { if abs(dragY) > 300 { Circle() .testData(value: "circle") } else { Rectangle() .testData(value: "rectangle") } } private var gesture: some Gesture { DragGesture() .onChanged { value in dragY = value.translation.height } .onEnded { value in withAnimation(.spring()) { dragY = 0 } } } } ================================================ FILE: Examples/AxtExamples/AxtExamples/MoreButton.swift ================================================ import SwiftUI import Axt struct LessMenu: View { @State private var isPresented = false var body: some View { Button("...") { isPresented = true } .testId("more_button", type: .button) .sheet(isPresented: $isPresented) { MoreMenu() .hostAxtSheet() } } } struct MoreMenu: View { var body: some View { Text("What's more?") .testId("more_text", type: .text) } } ================================================ FILE: Examples/AxtExamples/AxtExamples/NativeViews.swift ================================================ import SwiftUI struct NativeViews: View { @State var counter = 0 @State var name = "" var body: some View { List { let counterDescription = "Counter: \(counter)" Text(counterDescription) .testId("counter_label", type: .text) Button("Tap", action: { counter += 1 }) .testId("tap_button", type: .button) NavigationLink("More", destination: Text("More...")) .testId("more_link", type: .navigationLink) TextField("Name", text: $name) .testId("name_field", type: .textField) } } } ================================================ FILE: Examples/AxtExamples/AxtExamples/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/AxtExamples/AxtExamples/TogglesView.swift ================================================ import Foundation import SwiftUI import Axt struct TogglesView: View { @State var showMore = false @State var value1 = false @State var value2 = false @State var value3 = false @State var value4 = false var body: some View { List { Toggle("1", isOn: $value1) .testId("toggle_1", type: .toggle) Toggle("Show more", isOn: $showMore) .testId("show_more", type: .toggle) if showMore { Toggle("2", isOn: $value2) .testId("toggle_2", type: .toggle) Toggle("3", isOn: $value3) .testId("toggle_3", type: .toggle) Toggle("4", isOn: $value4) .testId("toggle_4", type: .toggle) } } } } ================================================ FILE: Examples/AxtExamples/AxtExamples.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 55; objects = { /* Begin PBXBuildFile section */ 2E45AE70287577060042F247 /* NativeViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E45AE6F287577060042F247 /* NativeViews.swift */; }; 2E45AE72287577BF0042F247 /* NativeViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E45AE71287577BF0042F247 /* NativeViewsTests.swift */; }; 2E8DFD092874755F0030D715 /* AxtExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8DFD082874755F0030D715 /* AxtExamplesApp.swift */; }; 2E8DFD0B2874755F0030D715 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8DFD0A2874755F0030D715 /* ContentView.swift */; }; 2E8DFD0D287475600030D715 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2E8DFD0C287475600030D715 /* Assets.xcassets */; }; 2E8DFD10287475600030D715 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2E8DFD0F287475600030D715 /* Preview Assets.xcassets */; }; 2E8DFD1A287475600030D715 /* TogglesViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8DFD19287475600030D715 /* TogglesViewTests.swift */; }; 2E8DFD332874758B0030D715 /* TogglesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8DFD322874758B0030D715 /* TogglesView.swift */; }; 2E8DFD39287476A90030D715 /* Axt in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8DFD38287476A90030D715 /* Axt */; }; 2E8DFD3D287476BA0030D715 /* Axt in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8DFD3C287476BA0030D715 /* Axt */; }; 2EB4C36B28758F520026D3C0 /* CustomControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C36A28758F520026D3C0 /* CustomControls.swift */; }; 2EB4C36D287593220026D3C0 /* CustomControlsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C36C287593220026D3C0 /* CustomControlsTests.swift */; }; 2EB4C36F287593FD0026D3C0 /* ConfirmationAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C36E287593FD0026D3C0 /* ConfirmationAlertModifier.swift */; }; 2EB4C3712875959D0026D3C0 /* ConfirmationAlertModifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C3702875959D0026D3C0 /* ConfirmationAlertModifierTests.swift */; }; 2EB4C3732875A2830026D3C0 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C3722875A2830026D3C0 /* GestureView.swift */; }; 2EB4C3752875A4D40026D3C0 /* GestureViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C3742875A4D40026D3C0 /* GestureViewTests.swift */; }; 2EB4C3772875A8500026D3C0 /* MoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C3762875A8500026D3C0 /* MoreButton.swift */; }; 2EB4C3792875A8F90026D3C0 /* MoreButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4C3782875A8F90026D3C0 /* MoreButtonTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 2E8DFD16287475600030D715 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 2E8DFCFD2874755F0030D715 /* Project object */; proxyType = 1; remoteGlobalIDString = 2E8DFD042874755F0030D715; remoteInfo = AxtExamples; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ 2E45AE6F287577060042F247 /* NativeViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeViews.swift; sourceTree = ""; }; 2E45AE71287577BF0042F247 /* NativeViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeViewsTests.swift; sourceTree = ""; }; 2E8DFD052874755F0030D715 /* AxtExamples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AxtExamples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2E8DFD082874755F0030D715 /* AxtExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AxtExamplesApp.swift; sourceTree = ""; }; 2E8DFD0A2874755F0030D715 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 2E8DFD0C287475600030D715 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2E8DFD0F287475600030D715 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 2E8DFD15287475600030D715 /* AxtExamplesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AxtExamplesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2E8DFD19287475600030D715 /* TogglesViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TogglesViewTests.swift; sourceTree = ""; }; 2E8DFD322874758B0030D715 /* TogglesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TogglesView.swift; sourceTree = ""; }; 2E8DFD34287476260030D715 /* Axt */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Axt; path = ../..; sourceTree = ""; }; 2EB4C36A28758F520026D3C0 /* CustomControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomControls.swift; sourceTree = ""; }; 2EB4C36C287593220026D3C0 /* CustomControlsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomControlsTests.swift; sourceTree = ""; }; 2EB4C36E287593FD0026D3C0 /* ConfirmationAlertModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationAlertModifier.swift; sourceTree = ""; }; 2EB4C3702875959D0026D3C0 /* ConfirmationAlertModifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationAlertModifierTests.swift; sourceTree = ""; }; 2EB4C3722875A2830026D3C0 /* GestureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureView.swift; sourceTree = ""; }; 2EB4C3742875A4D40026D3C0 /* GestureViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureViewTests.swift; sourceTree = ""; }; 2EB4C3762875A8500026D3C0 /* MoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreButton.swift; sourceTree = ""; }; 2EB4C3782875A8F90026D3C0 /* MoreButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreButtonTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 2E8DFD022874755F0030D715 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 2E8DFD39287476A90030D715 /* Axt in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 2E8DFD12287475600030D715 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 2E8DFD3D287476BA0030D715 /* Axt in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 2E8DFCFC2874755F0030D715 = { isa = PBXGroup; children = ( 2E8DFD34287476260030D715 /* Axt */, 2E8DFD072874755F0030D715 /* AxtExamples */, 2E8DFD18287475600030D715 /* AxtExamplesTests */, 2E8DFD062874755F0030D715 /* Products */, 2E8DFD37287476A90030D715 /* Frameworks */, ); sourceTree = ""; }; 2E8DFD062874755F0030D715 /* Products */ = { isa = PBXGroup; children = ( 2E8DFD052874755F0030D715 /* AxtExamples.app */, 2E8DFD15287475600030D715 /* AxtExamplesTests.xctest */, ); name = Products; sourceTree = ""; }; 2E8DFD072874755F0030D715 /* AxtExamples */ = { isa = PBXGroup; children = ( 2E8DFD082874755F0030D715 /* AxtExamplesApp.swift */, 2E8DFD0A2874755F0030D715 /* ContentView.swift */, 2E8DFD322874758B0030D715 /* TogglesView.swift */, 2E45AE6F287577060042F247 /* NativeViews.swift */, 2EB4C36A28758F520026D3C0 /* CustomControls.swift */, 2EB4C36E287593FD0026D3C0 /* ConfirmationAlertModifier.swift */, 2EB4C3722875A2830026D3C0 /* GestureView.swift */, 2EB4C3762875A8500026D3C0 /* MoreButton.swift */, 2E8DFD0C287475600030D715 /* Assets.xcassets */, 2E8DFD0E287475600030D715 /* Preview Content */, ); path = AxtExamples; sourceTree = ""; }; 2E8DFD0E287475600030D715 /* Preview Content */ = { isa = PBXGroup; children = ( 2E8DFD0F287475600030D715 /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; 2E8DFD18287475600030D715 /* AxtExamplesTests */ = { isa = PBXGroup; children = ( 2E8DFD19287475600030D715 /* TogglesViewTests.swift */, 2E45AE71287577BF0042F247 /* NativeViewsTests.swift */, 2EB4C36C287593220026D3C0 /* CustomControlsTests.swift */, 2EB4C3702875959D0026D3C0 /* ConfirmationAlertModifierTests.swift */, 2EB4C3742875A4D40026D3C0 /* GestureViewTests.swift */, 2EB4C3782875A8F90026D3C0 /* MoreButtonTests.swift */, ); path = AxtExamplesTests; sourceTree = ""; }; 2E8DFD37287476A90030D715 /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 2E8DFD042874755F0030D715 /* AxtExamples */ = { isa = PBXNativeTarget; buildConfigurationList = 2E8DFD29287475600030D715 /* Build configuration list for PBXNativeTarget "AxtExamples" */; buildPhases = ( 2E8DFD012874755F0030D715 /* Sources */, 2E8DFD022874755F0030D715 /* Frameworks */, 2E8DFD032874755F0030D715 /* Resources */, ); buildRules = ( ); dependencies = ( 2E8DFD36287476A50030D715 /* PBXTargetDependency */, ); name = AxtExamples; packageProductDependencies = ( 2E8DFD38287476A90030D715 /* Axt */, ); productName = AxtExamples; productReference = 2E8DFD052874755F0030D715 /* AxtExamples.app */; productType = "com.apple.product-type.application"; }; 2E8DFD14287475600030D715 /* AxtExamplesTests */ = { isa = PBXNativeTarget; buildConfigurationList = 2E8DFD2C287475600030D715 /* Build configuration list for PBXNativeTarget "AxtExamplesTests" */; buildPhases = ( 2E8DFD11287475600030D715 /* Sources */, 2E8DFD12287475600030D715 /* Frameworks */, 2E8DFD13287475600030D715 /* Resources */, ); buildRules = ( ); dependencies = ( 2E8DFD3B287476B40030D715 /* PBXTargetDependency */, 2E8DFD17287475600030D715 /* PBXTargetDependency */, ); name = AxtExamplesTests; packageProductDependencies = ( 2E8DFD3C287476BA0030D715 /* Axt */, ); productName = AxtExamplesTests; productReference = 2E8DFD15287475600030D715 /* AxtExamplesTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 2E8DFCFD2874755F0030D715 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1330; LastUpgradeCheck = 1330; TargetAttributes = { 2E8DFD042874755F0030D715 = { CreatedOnToolsVersion = 13.3.1; }; 2E8DFD14287475600030D715 = { CreatedOnToolsVersion = 13.3.1; TestTargetID = 2E8DFD042874755F0030D715; }; }; }; buildConfigurationList = 2E8DFD002874755F0030D715 /* Build configuration list for PBXProject "AxtExamples" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 2E8DFCFC2874755F0030D715; productRefGroup = 2E8DFD062874755F0030D715 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 2E8DFD042874755F0030D715 /* AxtExamples */, 2E8DFD14287475600030D715 /* AxtExamplesTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 2E8DFD032874755F0030D715 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 2E8DFD10287475600030D715 /* Preview Assets.xcassets in Resources */, 2E8DFD0D287475600030D715 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 2E8DFD13287475600030D715 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 2E8DFD012874755F0030D715 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 2E8DFD0B2874755F0030D715 /* ContentView.swift in Sources */, 2EB4C36F287593FD0026D3C0 /* ConfirmationAlertModifier.swift in Sources */, 2EB4C3772875A8500026D3C0 /* MoreButton.swift in Sources */, 2E45AE70287577060042F247 /* NativeViews.swift in Sources */, 2EB4C3732875A2830026D3C0 /* GestureView.swift in Sources */, 2EB4C36B28758F520026D3C0 /* CustomControls.swift in Sources */, 2E8DFD332874758B0030D715 /* TogglesView.swift in Sources */, 2E8DFD092874755F0030D715 /* AxtExamplesApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 2E8DFD11287475600030D715 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 2E8DFD1A287475600030D715 /* TogglesViewTests.swift in Sources */, 2EB4C36D287593220026D3C0 /* CustomControlsTests.swift in Sources */, 2EB4C3792875A8F90026D3C0 /* MoreButtonTests.swift in Sources */, 2EB4C3712875959D0026D3C0 /* ConfirmationAlertModifierTests.swift in Sources */, 2EB4C3752875A4D40026D3C0 /* GestureViewTests.swift in Sources */, 2E45AE72287577BF0042F247 /* NativeViewsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 2E8DFD17287475600030D715 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 2E8DFD042874755F0030D715 /* AxtExamples */; targetProxy = 2E8DFD16287475600030D715 /* PBXContainerItemProxy */; }; 2E8DFD36287476A50030D715 /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = 2E8DFD35287476A50030D715 /* Axt */; }; 2E8DFD3B287476B40030D715 /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = 2E8DFD3A287476B40030D715 /* Axt */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 2E8DFD27287475600030D715 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 2E8DFD28287475600030D715 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 2E8DFD2A287475600030D715 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"AxtExamples/Preview Content\""; DEVELOPMENT_TEAM = JFV288DQ9Q; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.soundcloud.AxtExamples; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 2E8DFD2B287475600030D715 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"AxtExamples/Preview Content\""; DEVELOPMENT_TEAM = JFV288DQ9Q; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.soundcloud.AxtExamples; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; 2E8DFD2D287475600030D715 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = JFV288DQ9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.soundcloud.AxtExamplesTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AxtExamples.app/AxtExamples"; }; name = Debug; }; 2E8DFD2E287475600030D715 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = JFV288DQ9Q; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.soundcloud.AxtExamplesTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AxtExamples.app/AxtExamples"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 2E8DFD002874755F0030D715 /* Build configuration list for PBXProject "AxtExamples" */ = { isa = XCConfigurationList; buildConfigurations = ( 2E8DFD27287475600030D715 /* Debug */, 2E8DFD28287475600030D715 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 2E8DFD29287475600030D715 /* Build configuration list for PBXNativeTarget "AxtExamples" */ = { isa = XCConfigurationList; buildConfigurations = ( 2E8DFD2A287475600030D715 /* Debug */, 2E8DFD2B287475600030D715 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 2E8DFD2C287475600030D715 /* Build configuration list for PBXNativeTarget "AxtExamplesTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 2E8DFD2D287475600030D715 /* Debug */, 2E8DFD2E287475600030D715 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ 2E8DFD35287476A50030D715 /* Axt */ = { isa = XCSwiftPackageProductDependency; productName = Axt; }; 2E8DFD38287476A90030D715 /* Axt */ = { isa = XCSwiftPackageProductDependency; productName = Axt; }; 2E8DFD3A287476B40030D715 /* Axt */ = { isa = XCSwiftPackageProductDependency; productName = Axt; }; 2E8DFD3C287476BA0030D715 /* Axt */ = { isa = XCSwiftPackageProductDependency; productName = Axt; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2E8DFCFD2874755F0030D715 /* Project object */; } ================================================ FILE: Examples/AxtExamples/AxtExamples.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Examples/AxtExamples/AxtExamples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: Examples/AxtExamples/AxtExamplesTests/ConfirmationAlertModifierTests.swift ================================================ import XCTest @testable import AxtExamples import Axt import SwiftUI @MainActor class ConfirmationAlertModifierTests: XCTestCase { // func testWatch() async { // struct MyView: View { // @State var alertPresented = false // var body: some View { // Button("Present alert") { alertPresented.toggle() } // .modifier(ConfirmationAlertModifier(isPresented: $alertPresented, message: "Are you sure?", action1: { print("yes") }, action2: { print("no") })) // } // } // let test = await AxtTest.host(MyView()) // await test.watchHierarchy() // } } ================================================ FILE: Examples/AxtExamples/AxtExamplesTests/CustomControlsTests.swift ================================================ import XCTest @testable import AxtExamples import Axt @MainActor class CustomControlsTests: XCTestCase { // func testWatch() async { // let test = await AxtTest.host(CustomControls()) // await test.watchHierarchy() // } } ================================================ FILE: Examples/AxtExamples/AxtExamplesTests/GestureViewTests.swift ================================================ import XCTest @testable import AxtExamples import Axt import SwiftUI @MainActor class GestureViewTests: XCTestCase { // func testWatch() async { // let test = await AxtTest.host(GestureView()) // // await test.watchHierarchy() // } func testKnob() async throws { let test = await AxtTest.host(GestureView()) let knob = try XCTUnwrap(test.find(id: "knob")) let dragValue = try XCTUnwrap(test.find(id: "drag")) let drag: CGFloat = 500.0 dragValue.setValue(drag) await AxtTest.yield() XCTAssertEqual(knob.value as? String, "circle") } } ================================================ FILE: Examples/AxtExamples/AxtExamplesTests/MoreButtonTests.swift ================================================ import XCTest @testable import AxtExamples import Axt import SwiftUI @MainActor class MoreButtonTests: XCTestCase { func test_sheet() async throws { let test = await AxtTest.host(LessMenu()) let button = try XCTUnwrap(test.find(id: "more_button")) await button.performAction() let sheet = try XCTUnwrap(AxtTest.sheets.first?.value) XCTAssertNotNil(sheet.find(id: "more_text")) } } ================================================ FILE: Examples/AxtExamples/AxtExamplesTests/NativeViewsTests.swift ================================================ import XCTest @testable import AxtExamples import Axt @MainActor class NativeViewsTests: XCTestCase { // func testWatch() async { // let test = await AxtTest.host(NativeViews()) // await test.watchHierarchy() // } } ================================================ FILE: Examples/AxtExamples/AxtExamplesTests/TogglesViewTests.swift ================================================ import XCTest @testable import AxtExamples import Axt @MainActor class TogglesViewTests: XCTestCase { func testWatch() async { let test = await AxtTest.host(TogglesView()) await test.watchHierarchy() } func testShowMore() async throws { let test = await AxtTest.host(TogglesView()) let moreToggle = try XCTUnwrap(test.find(id: "show_more")) moreToggle.performActionWithoutYielding() try await test.waitForCondition(timeout: 1) { test.find(id: "toggle_2") != nil } } } ================================================ FILE: Package.swift ================================================ // swift-tools-version: 5.6 import PackageDescription let package = Package( name: "Axt", platforms: [.iOS(.v14)], products: [ .library( name: "Axt", targets: ["Axt"]), ], targets: [ .target( name: "Axt", dependencies: [], swiftSettings: [ .define("TESTABLE", .when(configuration: .debug)) ]), ] ) ================================================ FILE: README.md ================================================ # 🪓 Axt ![](https://user-images.githubusercontent.com/13484323/185608030-21c45ddc-f90b-42e9-a8ac-e855bb090aea.svg) ![](https://user-images.githubusercontent.com/13484323/185608132-f90bd70e-4518-404d-9ba7-c24739f7c2b2.svg) ![](https://user-images.githubusercontent.com/13484323/200010051-3270dd90-1edd-42ff-b94f-cb8ba3618a4e.svg) Axt is a testing library for SwiftUI. Unit tests using Axt can interact with SwiftUI views, which are running live in the simulator and are in a fully functional state. ```swift struct MyView: View { @State var showMore = false var body: some View { VStack { Toggle("Show more", isOn: $showMore) .testId("show_more_toggle", type: .toggle) if showMore { Text("More") .testId("more_text", type: .text) } } } } ``` ```swift @MainActor class MyViewTests: XCTestCase { func testShowMore() async throws { let test = await AxtTest.host(MyView()) let showMoreToggle = test.find(id: "show_more_toggle") await showMoreToggle?.performAction() XCTAssertEqual(showMoreToggle?.value as? Bool, true) XCTAssertEqual(test.find(id: "more_text")?.label, "More") } } ``` ## Getting started Follow the steps below to add Axt to an existing project. Note that Axt should be used with unit test targets, and not with UI test targets. 1. Add the Axt Swift package as a dependency to your Xcode project. 2. Link both your app target and unit test target to the Axt library. If the project is built for release, it will only contain stubs for Axt and no inspection code. 3. Make sure your unit test target has a host application. We need some app to host the views to test, but the views do not need to be part of this host application. ## Documentation ### Exposing views To expose a view, you give it an identifier with the `testId` modifier. Take this list of toggles, and notice the `toggle_1`, `show_more` and `toggle_2` identifiers. ```swift List { Toggle("1", isOn: $value1) .testId("toggle_1", type: .toggle) Toggle("Show more", isOn: $showMore) .testId("show_more", type: .toggle) if showMore { Toggle("2", isOn: $value2) .testId("toggle_2", type: .toggle) } } .testId("toggle_list") ``` This will be exposed to the tests as below. ``` → app → toggle_list → toggle_1 label="1" value=false action → show_more label="Show more" value=false action ``` There are different ways to expose views to unit tests, depending on whether they are built-in or custom views. You can also attach Axt elements without explicit child views to a view. #### Native views To enable Axt on native SwiftUI views, you need to tell Axt what kind of view it needs to look for. The following built-in views are supported. ##### Button ```swift Button("Tap me") { tap() } .testId("tap_button", type: .button) ``` ``` → tap_button label="Tap me" action ``` ##### Toggle ```swift Toggle("Toggle me", isOn: $isOn) .testId("is_on_toggle", type: .toggle) ``` ``` → is_on_toggle label="Toggle me" value=true action ``` ##### NavigationLink ```swift NavigationLink("More", destination: Destination()) .testId("more_link", type: .navigationLink) ``` ``` → more_link label="More" action ``` ##### TextField ```swift TextField("Name", text: $name) .testId("name_field", type: .textField) ``` ``` → name_field label="Name" value="" action ``` #### Custom views For custom views, you can specify values or functionality manually to expose them to views. ```swift Color.blue.frame(width: 50, height: 50) .testId("color_1", value: "blue") Color.red.frame(width: 50, height: 50) .testId("color_2", value: "red") ``` These can now be accessed from tests. ``` → app → color_1 value=blue → color_2 value=red ``` You can also add closures to perform from tests (using the `action` parameter) or a way to set a value (using the `setValue` parameter). #### Re-usable controls It is common to want to specify values or functionality for re-usable controls, but allow clients to set the test identifier or override values or functionality. This would be the case for custom buttons or search bars. For this, use the `testData` modifier. ```swift struct MyButton: View { let action: () -> Void var body: some View { Button("Tap me!") { action() } .testData(action: action) } } MyButton(action: action) .testId("my_button") ``` There will only be a single element for this button exposed to the tests. ``` → app → my_button action ``` Using the `testData` modifier only results in an element exposed to tests, if an identifier is provided somewhere higher up in the view hierarchy. Do not use the `testId(:type:)` modifiers for native views on custom controls. For custom controls, extracting data from views is not necessary. #### Inserting extra elements Sometimes it can be useful to insert Axt elements that do not correspond to a SwiftUI view. This can be useful to expose buttons that are handled in UIKit, or to interact with gestures or other objects that are not views, or provide an easy way to interact with view state when testing a view modifier. For example, here is how we can expose the contents of an alert. ```swift content.alert(isPresented: $isPresented) { Alert( title: Text(message), primaryButton: .default(Text("1"), action: action1), secondaryButton: .default(Text("2"), action: action2)) } .testId(insert: "button_1", when: isPresented, label: "1", action: action1) .testId(insert: "button_2", when: isPresented, label: "2", action: action2) ``` The elements will be exposed as siblings. ``` → app → button_1 label="1" action → button_2 label="2" action ``` And here we expose a drag gesture to be testable. ```swift @State private var dragY: CGFloat = 0 var body: some View { knob .frame(width: 50, height: 50) .offset(x: 0, y: dragY) .gesture(gesture) .testId(insert: "drag", value: dragY, setValue: { dragY = $0 as? CGFloat ?? 0 }) } ``` ``` → app → drag value=0.0 ``` #### Sheets Preferences that are set on the contents of a SwiftUI sheet are never transferred to the view presenting the sheet. You can still expose contents of a sheet, but this should be a last resort. Use the following code to add a new `AxtTest` to the `AxtTest.sheets` variable. ```swift Button("...") { isPresented = true } .sheet(isPresented: $isPresented) { MoreMenu() .hostAxtSheet() } ``` ### Writing tests The first step to writing an Axt test is to create an asynchronous test method, and to host an Axt test with the view. ```swift func test_myView() async { let test = await AxtTest.host(MyView()) // ... ``` In addition to creating the test, this will also display `MyView` in the simulator or iPhone. It will be displayed with a red border around it, to indicite that it is presented by Axt and distinguish it from the rest of the app contents. #### Watch the hierarchy As a first step, we can watch view updates in the console. ```swift await test.watchHierarchy() ``` Running this test prints the current view hierarchy in the console. The view is also interactive. If you interact with the view, a new view hierarchy will be printed in the console any time it changes. #### Finding views The `test` we created before is also an Axt element, namely the root element. If you have an element, you can use it to search for other elements. You can use the `find(id: "my_button")` method to recursively search for an element with id `my_button`, or `findAll(id: "my_button")` to get an array of all the elements with this id. ```swift let myButton = try XCTUnwrap(test.find(id: "my_button")) ``` You can also get the direct children of an element using the `children` method. To recursively get all elements underneath another element, use the `all` property instead. #### Assert on elements You can check if an Axt element (still) exists (`exists`). It has an identifier given to it through the `testId` modifier (`id`), and optionally a label (`label`), value (`value`), way to perform an action (`performAction()`), and way to set the value (`setValue`). For any Axt element, you can use `await element.watchHierarchy()` to see how the hierarchy changes while interacting with it in the simulator or on your iPhone. #### The lifetime of Axt elements An Axt element points to a view that is exposed to Axt by the methods presented before, but it differs to a view in that it is a reference type. If a view is re-evaluated, an Axt element that points to that view will be updated, but the same object. The Axt element will track changes in the view. That means you can store an Axt element, make changes to the SwiftUI state, and then check the Axt element again. ```swift let test = await AxtTest.host(MyView()) let label = try XCTUnwrap(test.find(id: "my_label") let toggle = try XCTUnwrap(test.find(id: "my_toggle")) XCTAssertEqual(label.value as? String, "yes") await toggle.performAction() XCTAssertEqual(label.value as? String, "no") ``` #### Waiting for view updates If you change the state of a variable in a SwiftUI view, for example by performing an action on a control or changing a value, SwiftUI will trigger a re-evaluation of your view. However, SwiftUI does not re-evaluate the view immediately. This is done for efficiency reasons. Therefore, you cannot make an assertion immediately after changing state. If you expect an update to happen after an action immediately after the current run loop cycle, use `performAction()`. If you don't want to give SwiftUI the time to update the views, use `performActionWithoutYielding()` instead. You can then give SwiftUI the time to update the views by calling `AxtTest.yield()`. ```swift let test = await AxtTest.host(TogglesView()) let moreToggle = try XCTUnwrap(test.find(id: "show_more")) moreToggle.performActionWithoutYielding() await AxtTest.yield() XCTAssertNotNil(test.find(id: "toggle_2")) ``` If you expect that it might take longer for the view hierarchy to update, for example because the changes are animated, you can use the `waitFor` functions on Axt elements. These functions are efficient, because they only check for changes when the view hierarchy was changed. ```swift let test = await AxtTest.host(TogglesView()) let moreToggle = try XCTUnwrap(test.find(id: "show_more")) await moreToggle.performAction() XCTAssertNotNil(try await test.waitForElement(id: "toggle_2", timeout: 1)) ``` There is also `waitForCondition` to wait for any boolean condition, and `waitForUpdate` that returns as soon as anything in the view hierarchy is changed. ================================================ FILE: Sources/Axt/Axt.swift ================================================ import SwiftUI public struct Axt: Equatable { public static func == (lhs: Axt, rhs: Axt) -> Bool { lhs._uuid == rhs._uuid } /// Changes every time the Axt is re-evaluated public let _uuid = UUID() /// User-defined identifier public let id: String? /// Unique, does not change when Axt is re-evaluated public let nodeId: UUID public let label: String? public let value: Any? public let action: (() -> Void)? public let setValue: ((Any?) -> Void)? public let children: [Axt] public let visible: Bool } public struct AxtPreferenceKey: PreferenceKey { public static var defaultValue: [Axt] = [] public static func reduce(value: inout [Axt], nextValue: () -> [Axt]) { let axts = nextValue() for axt in axts { // In iOS 16, List has a bug where preferences can appear // more than once, so we need to check for duplicates. if !value.contains(where: { $0.nodeId == axt.nodeId }) { value.append(axt) } } } } ================================================ FILE: Sources/Axt/AxtTest/Axt+description.swift ================================================ import Foundation #if TESTABLE extension Axt { func describeHierarchy() -> String { describeHierarchy(level: 0) } private func describeHierarchy(level: Int = 0) -> String { var description = "" let indent = Array(repeating: " ", count: level * 2).joined() description += indent + "→ " + describeNode() + "\n" for child in children { description += child.describeHierarchy(level: level + 1) } return description } private func describeNode() -> String { var description = id ?? "" if let label = self.label { description.append(" label=\"" + label + "\"") } if let value = self.value { description.append(" value=" + String(describing: value)) } if action != nil { description.append(" action") } if !visible { description.append(" hidden") } return description } } #endif ================================================ FILE: Sources/Axt/AxtTest/Axt+find.swift ================================================ import Foundation #if TESTABLE extension Axt { func find(where condition: (Axt) -> Bool) -> Axt? { if condition(self) { return self } for child in children { if let match = child.find(where: condition) { return match } } return nil } var all: [Axt] { var all: [Axt] = [] for child in children { all.append(child) all.append(contentsOf: child.all) } return all } } #endif ================================================ FILE: Sources/Axt/AxtTest/AxtChildNode.swift ================================================ import Combine import Foundation #if TESTABLE class AxtChildNode: AxtNode { let nodeId: UUID let getRoot: () -> Axt let rootDidChange: AnyPublisher init(nodeId: UUID, getRoot: @escaping () -> Axt, rootDidChange: AnyPublisher) { self.nodeId = nodeId self.getRoot = getRoot self.rootDidChange = rootDidChange } } #endif ================================================ FILE: Sources/Axt/AxtTest/AxtElement.swift ================================================ import Combine import SwiftUI #if TESTABLE /// Automatically updated when the view it refers to changes public protocol AxtElement { var exists: Bool { get } var id: String! { get } var label: String? { get } var value: Any? { get } var children: [AxtElement] { get } var all: [AxtElement] { get } /// Recursively search for an element func find(id: String) -> AxtElement? func findAll(id: String) -> [AxtElement] func performActionWithoutYielding() func setValue(_ value: Any?) func waitForCondition(timeout: TimeInterval, condition: @escaping () -> Bool) async throws func waitForElement(id: String, timeout: TimeInterval) async throws -> AxtElement func waitForUpdate(timeout: TimeInterval) async throws func watchHierarchy() async } public extension AxtElement { func performAction() async { performActionWithoutYielding() await AxtTest.yield() } } #endif ================================================ FILE: Sources/Axt/AxtTest/AxtNode.swift ================================================ import Combine import Foundation #if TESTABLE protocol AxtNode: AxtElement { var nodeId: UUID { get } var getRoot: () -> Axt { get } var rootDidChange: AnyPublisher { get } } extension AxtNode { var axt: Axt! { getRoot().find(where: { $0.nodeId == self.nodeId }) } func makeNode(nodeId: UUID) -> AxtChildNode { AxtChildNode(nodeId: nodeId, getRoot: getRoot, rootDidChange: rootDidChange) } public var exists: Bool { axt != nil } public var id: String! { axt?.id } public var label: String? { axt?.label } public var value: Any? { axt?.value } public var children: [AxtElement] { axt?.children.map { makeNode(nodeId: $0.nodeId) } ?? [] } public var all: [AxtElement] { axt?.all.map { makeNode(nodeId: $0.nodeId) } ?? [] } public func find(id: String) -> AxtElement? { if let axt = axt?.find(where: { $0.id == id }) { return makeNode(nodeId: axt.nodeId) } return nil } public func findAll(id: String) -> [AxtElement] { (axt?.all ?? []).filter { $0.id == id }.map { makeNode(nodeId: $0.nodeId) } } public func performActionWithoutYielding() { axt?.action?() } public func setValue(_ value: Any?) { axt?.setValue?(value) } public func waitForCondition(timeout: TimeInterval, condition: @escaping () -> Bool) async throws { try await rootDidChange.filter { condition() }.firstValue(timeout: timeout) } public func waitForElement(id: String, timeout: TimeInterval) async throws -> AxtElement { let axt = try await rootDidChange.map { self.axt } .compactMap { axt in axt.find(where: { child in child.id == id }) } .firstValue(timeout: timeout) return makeNode(nodeId: axt.nodeId) } public func waitForUpdate(timeout: TimeInterval) async throws { _ = try await rootDidChange.map { self.axt } .removeDuplicates() .firstValue(timeout: timeout) } public func watchHierarchy() async { for await description in rootDidChange.compactMap({ self.axt?.describeHierarchy() }).prepend([axt.describeHierarchy()]).values { print("────────") print(description) } } } #endif ================================================ FILE: Sources/Axt/AxtTest/AxtTest.swift ================================================ import Combine import SwiftUI #if TESTABLE public final class AxtTest { private var window: UIWindow! public private(set) var hostingController: UIViewController! public internal(set) static var sheets: [UUID: AxtTest] = [:] public internal(set) static var enabled = false private let axtSubject = CurrentValueSubject(nil) public init(_ view: V) { let host = HostView(content: view, axtSubject: axtSubject) hostingController = UIHostingController(rootView: host) } @MainActor public static func host(_ view: V) async -> AxtTest { let axTest = AxtTest(view) axTest.makeWindow() _ = await axTest.axtSubject.dropFirst().values.first { _ in true } return axTest } public func makeWindow() { Self.enabled = true let windowScenes = UIApplication.shared.connectedScenes guard let scene = windowScenes.first as? UIWindowScene else { fatalError("Could not connect to window scene, make sure the test is running from a host application.") } window = UIWindow(windowScene: scene) window.rootViewController = hostingController window.makeKeyAndVisible() } public var app: AxtElement { self } /// Let the current runloop cycle finish. /// Most of the time this is enough to let SwiftUI re-evaluate any /// properties and views, and can be used instead of waiting with a /// time-out. public static func yield() async { return await withCheckedContinuation { cont in DispatchQueue.main.async { [cont] in cont.resume() } } } } extension AxtTest: AxtNode { var nodeId: UUID { axtSubject.value!.nodeId } var getRoot: () -> Axt { { self.axtSubject.value! } } var rootDidChange: AnyPublisher { axtSubject.map { _ in }.dropFirst().eraseToAnyPublisher() } } private struct HostView: View { let content: Content let axtSubject: CurrentValueSubject var body: some View { content .border(.red, width: 2) .padding() .testId("app") .backgroundPreferenceValue(AxtPreferenceKey.self) { // This is used instead of `onPreferenceChange` because that // has a safety mechanism that results in the closure not // being called anymore if the preference is updated more than // two times before rendering. // The safety mechanism prevents infinite loops that can happen // when updating a state in response to a preference change, // which then causes the preference to be updated again, but we // do not need that here. let _ = axtSubject.send($0.first) Color.clear } } } #endif ================================================ FILE: Sources/Axt/AxtTest/Publisher+Compatibility.swift ================================================ import Combine #if TESTABLE @available(iOS, deprecated: 15.0, message: "For iOS 14 compatibility") extension Publisher { var values: AsyncThrowingStream { AsyncThrowingStream { continuation in let cancellable = sink( receiveCompletion: { completion in switch completion { case .finished: continuation.finish() case let .failure(error): continuation.finish(throwing: error) } }, receiveValue: { value in continuation.yield(value) } ) continuation.onTermination = { @Sendable _ in cancellable.cancel() } } } } @available(iOS, deprecated: 15.0, message: "For iOS 14 compatibility") public extension Publisher where Failure == Never { var values: AsyncStream { AsyncStream { continuation in let cancellable = sink( receiveCompletion: { _ in continuation.finish() }, receiveValue: { value in continuation.yield(value) } ) continuation.onTermination = { @Sendable _ in cancellable.cancel() } } } } #endif ================================================ FILE: Sources/Axt/AxtTest/Publisher+firstValue.swift ================================================ import Combine import Foundation #if TESTABLE public struct TimeOut: Error {} public extension Publisher where Failure == Never { /// Use only for testing /// Blocks the execution and returns the first value published by the /// publisher. If there's no value received during the `timeout` period, /// throws the `TimeOut` error. func firstValue(timeout: TimeInterval = 1) async throws -> Output { let value = await self.timeout(.seconds(timeout), scheduler: DispatchQueue.main) .values .first { _ in true } guard let value = value else { throw TimeOut() } return value } } #endif ================================================ FILE: Sources/Axt/AxtTest/printHierarchy.swift ================================================ import Foundation #if TESTABLE /// For debugging purposes /// Prints the recursive hierarchy of a Swift structure using `Mirror(reflecting:)` public func printHierarchy(level: Int = 0, object: Any) { let indent = Array(repeating: " ", count: level * 2).joined() let mirror = Mirror(reflecting: object) print(String(describing: type(of: object)), terminator: "") if mirror.children.isEmpty { print(" = " + String(describing: object), terminator: "") } print("") for property in mirror.children { print(indent + "→ " + (property.label ?? ""), terminator: ": ") printHierarchy(level: level + 1, object: property.value) } } #endif ================================================ FILE: Sources/Axt/AxtView.swift ================================================ import SwiftUI #if TESTABLE struct AxtView: View { let identifier: String? let label: String? let value: Any? let action: (() -> Void)? let setValue: ((Any?) -> Void)? let content: Content @State private var nodeId = UUID() @State private var visible = true var body: some View { content .transformPreference(AxtPreferenceKey.self) { value in if value.count == 1, let placeholder = value.first, placeholder.id == nil { value = [Axt(id: identifier, nodeId: self.nodeId , label: self.label ?? placeholder.label, value: self.value ?? placeholder.value, action: self.action ?? placeholder.action, setValue: self.setValue ?? placeholder.setValue, children: placeholder.children, visible: self.visible)] } else { value = [Axt(id: identifier, nodeId: self.nodeId, label: label, value: self.value, action: action, setValue: setValue, children: value, visible: self.visible)] } } .onAppear { visible = true } .onDisappear { visible = false } } } struct AxtInsertView: View { let identifier: String let condition: Bool let label: String? let value: Any? let action: (() -> Void)? let setValue: ((Any?) -> Void)? let content: Content @State private var nodeId = UUID() var body: some View { content .transformPreference(AxtPreferenceKey.self) { value in guard condition else { return } value.append(Axt(id: identifier, nodeId: self.nodeId, label: label, value: self.value, action: action, setValue: setValue, children: [], visible: true)) } } } #endif ================================================ FILE: Sources/Axt/Modifier.swift ================================================ import SwiftUI public protocol Modifier { associatedtype Content: View #if TESTABLE associatedtype Body: View func make(_ content: Content) -> Body #endif } ================================================ FILE: Sources/Axt/Native/Button.swift ================================================ import SwiftUI public struct ButtonModifier: Modifier { #if TESTABLE public func make(_ content: Content) -> some View { var action: (() -> Void)? let label = dig(for: String.self, in: content) ?? "" // Supresses a compiler warning typealias Void_ = Void // This type is used in `Button` if let buttonAction = dig(for: (() -> Void).self, in: content) { action = buttonAction // This type is used in tap gestures } else if let tapAction = dig(for: ((Void_) -> Void).self, in: content) { action = { tapAction(()) } } return content.testData(label: label, value: nil, action: action) } #endif } public extension NativeView { static var button: NativeView> { .init(base: .init()) } } ================================================ FILE: Sources/Axt/Native/NavigationLink.swift ================================================ import SwiftUI public struct NavigationLinkModifier: Modifier { #if TESTABLE public func make(_ content: Content) -> some View { let makeContent: (State) -> Content = { state in // This is modifying the navigation link directly and depends // on the fact that the isActive: State parameter is the // first parameter in the NavigationLink structure. var link = content withUnsafeMutablePointer(to: &link) { pointer in pointer.withMemoryRebound(to: State.self, capacity: 1) { statePointer in statePointer.pointee = state } } return link } return AxtNavigationLink(isActive: dig(for: State.self, in: content)!, content: makeContent) } #endif } #if TESTABLE private struct AxtNavigationLink: View { // Because a State variable is only ready once the body is called by // SwiftUI, you cannot access State variables from the parent of a view, // and they need to be moved up the hierarchy. @State var isActive: Bool let content: (State) -> Content init(isActive: State, content: @escaping ((State) -> Content)) { _isActive = isActive self.content = content } var body: some View { // Prevents SwiftUI from optimizing state updating away, so that it is // ready when used in the `activate` closure. // swiftformat:disable:next redundantLet let _ = isActive content(_isActive) .testData { isActive.toggle() } } } #endif public extension NativeView { static var navigationLink: NativeView> { .init(base: .init()) } } ================================================ FILE: Sources/Axt/Native/Text.swift ================================================ import SwiftUI public struct TextModifier: Modifier { #if TESTABLE public func make(_ content: Content) -> some View { var label: String? if let text = dig(for: Text.self, in: content) { label = dig(for: String.self, in: text) } return content.testData(label: label) } #endif } public extension NativeView { static var text: NativeView> { .init(base: .init()) } } ================================================ FILE: Sources/Axt/Native/TextField.swift ================================================ import SwiftUI public struct TextFieldModifier: Modifier { #if TESTABLE public func make(_ content: Content) -> some View { var label: String? if let text = dig(for: Text.self, in: content) { label = dig(for: String.self, in: text) } let _value = dig(for: Binding.self, in: content) let value = _value?.wrappedValue return content.testData(label: label, value: value, setValue: { if let newValue = $0 as? String { _value?.wrappedValue = newValue } }) } #endif } public extension NativeView { static var textField: NativeView> { .init(base: .init()) } } ================================================ FILE: Sources/Axt/Native/Toggle.swift ================================================ import SwiftUI public struct ToggleModifier: Modifier { #if TESTABLE public func make(_ content: Content) -> some View { let label = dig(for: String.self, in: content) ?? "" let isOn: Bool? let action: () -> Void if #available(iOS 16, *) { // Starting with iOS 16, the state of a toggle is no longer a Bool, // but an internal enum that can be in an on, off or mixed state. let anyToggleStateBinding = digForProperty(named: "_toggleState", in: content) let _toggleState = withUnsafePointer(to: anyToggleStateBinding) { $0.withMemoryRebound(to: Binding.self, capacity: 1) { $0.pointee } } switch _toggleState.wrappedValue { case .on: isOn = true case .off: isOn = false case .mixed: isOn = nil } action = { if _toggleState.wrappedValue == .off { _toggleState.wrappedValue = .on } else if _toggleState.wrappedValue == .on { _toggleState.wrappedValue = .off } } } else { let _isOn = dig(for: Binding.self, in: content) isOn = _isOn?.wrappedValue action = { _isOn?.wrappedValue.toggle() } } return content.testData(label: label, value: isOn, action: { withAnimation { action() } }) } #endif } public extension NativeView { static var toggle: NativeView> { .init(base: .init()) } } private enum ToggleState { case on case off case mixed } ================================================ FILE: Sources/Axt/NativeView.swift ================================================ import SwiftUI public struct NativeView { public let base: M } ================================================ FILE: Sources/Axt/View+testData.swift ================================================ import SwiftUI public extension View { @ViewBuilder func testData(type: NativeView, label: String? = nil, value: Any? = nil, action: (() -> Void)? = nil, setValue: ((Any?) -> Void)? = nil) -> some View where M.Content == Self { #if TESTABLE if AxtTest.enabled { AxtView( identifier: nil, label: label, value: value, action: action, setValue: setValue, content: type.base.make(self) ) } else { self } #else self #endif } @ViewBuilder func testData(label: String? = nil, value: Any? = nil, action: (() -> Void)? = nil, setValue: ((Any?) -> Void)? = nil) -> some View { #if TESTABLE if AxtTest.enabled { AxtView( identifier: nil, label: label, value: value, action: action, setValue: setValue, content: self ) } else { self } #else self #endif } } ================================================ FILE: Sources/Axt/View+testId.swift ================================================ import SwiftUI public extension View { @ViewBuilder func testId(_ identifier: String, type: NativeView, label: String? = nil, value: Any? = nil, action: (() -> Void)? = nil, setValue: ((Any?) -> Void)? = nil) -> some View where M.Content == Self { #if TESTABLE if AxtTest.enabled { AxtView( identifier: identifier, label: label, value: value, action: action, setValue: setValue, content: type.base.make(self) ) } else { self } #else self #endif } @ViewBuilder func testId(_ identifier: String, label: String? = nil, value: Any? = nil, action: (() -> Void)? = nil, setValue: ((Any?) -> Void)? = nil) -> some View { #if TESTABLE if AxtTest.enabled { AxtView( identifier: identifier, label: label, value: value, action: action, setValue: setValue, content: self ) } else { self } #else self #endif } @ViewBuilder func testId(insert identifier: String, when condition: Bool = true, label: String? = nil, value: Any? = nil, action: (() -> Void)? = nil, setValue: ((Any?) -> Void)? = nil) -> some View { #if TESTABLE if AxtTest.enabled { AxtInsertView( identifier: identifier, condition: condition, label: label, value: value, action: action, setValue: setValue, content: self ) } else { self } #else self #endif } } ================================================ FILE: Sources/Axt/dig.swift ================================================ import Foundation #if TESTABLE /// Recursively search for a property of a specific type in another object. func dig(for _: T.Type, in object: Any) -> T? { if let result = object as? T { return result } for child in Mirror(reflecting: object).children { if let result = dig(for: T.self, in: child.value) { return result } } return nil } /// Recursively search for a property with a specific name in another object. func digForProperty(named name: String, in object: Any) -> Any? { for child in Mirror(reflecting: object).children { if child.label == name { return child.value } if let result = digForProperty(named: name, in: child.value) { return result } } return nil } #endif ================================================ FILE: Sources/Axt/hostAxtSheet.swift ================================================ import SwiftUI public extension View { /// Use this modifier on the content **inside** of a sheet, so that the /// contents can be accessed using `AXTest.sheets`. func hostAxtSheet() -> some View { #if TESTABLE AxtSheet(content: self) #else self #endif } } #if TESTABLE struct AxtSheet: UIViewControllerRepresentable { let content: Content public struct Coordinator { let id: UUID } public func makeUIViewController(context: Context) -> UIViewController { let test = AxtTest(content) AxtTest.sheets[context.coordinator.id] = test return test.hostingController } public func updateUIViewController(_: UIViewController, context: Context) { let test = AxtTest.sheets[context.coordinator.id] (test?.hostingController as? UIHostingController)?.rootView = content } public static func dismantleUIViewController(_: UIViewController, coordinator: Coordinator) { AxtTest.sheets.removeValue(forKey: coordinator.id) } public func makeCoordinator() -> Coordinator { Coordinator(id: UUID()) } } #endif