← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit 3260eac5182505530cdfebba420c572da70c6071
parent da94b8b712a6b8494c65c16681b3b421be04f327
Author: Michael Camilleri <[email protected]>
Date:   Sat, 14 Mar 2026 18:02:31 +0900

Add UI tests for iOS

This adds UI tests for the iOS version of Listless. The tests are only
run on a simulated iPhone because XCUITest (amazingly) does not properly
support testing menu bar-related functionality on the iPad. If at some
future point, Apple adds this support, a separate UI test suite should
be added that will test iPad-specific code (primarily regarding keyboard
shortcuts).

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme | 12++++++++++++
MListlessiOS/Helpers/TappableTextField.swift | 6+++++-
MListlessiOS/Views/TaskListView.swift | 6++++--
MListlessiOS/Views/TaskRowView.swift | 5++++-
AScripts/test-ios-ui.sh | 8++++++++
ATests/UI/ListlessiOSUITests.swift | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 21++++++++++++++++++++-

8 files changed, 316 insertions(+), 5 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -21,6 +21,7 @@
 		19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
 		1A66A0454558B207AF9265D4 /* UndoToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658295C1386BFF48CE3C2419 /* UndoToast.swift */; };
 		1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; };
+		239F975836FD432A5FF04036 /* ListlessiOSUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */; };
 		264BD64C1DD30376E8BDAF79 /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; };
 		269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; };
 		26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */; };
@@ -131,6 +132,13 @@
 			remoteGlobalIDString = 0FB4F07A37999BBC6DFE4DBB;
 			remoteInfo = "Listless macOS";
 		};
+		EE50B14FCB0F89292F1E2A01 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3256C2BF8F1DAF371DA32120 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 34A03D42B91730DEAC2EBD8E;
+			remoteInfo = "Listless iOS";
+		};
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXCopyFilesBuildPhase section */
@@ -150,6 +158,7 @@
 /* Begin PBXFileReference section */
 		01E141436176F83594E2F26B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
 		067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowSwipeGesture.swift; sourceTree = "<group>"; };
+		0B750F1634E250256AF3FEB6 /* Listless iOS UI Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 		17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColor.swift; sourceTree = "<group>"; };
 		199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
@@ -184,6 +193,7 @@
 		75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
 		7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+SyncUI.swift"; sourceTree = "<group>"; };
 		7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
+		88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSUITests.swift; sourceTree = "<group>"; };
 		8AACB5E40BEBA75E7FE8B930 /* TaskListView+Undo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Undo.swift"; sourceTree = "<group>"; };
 		9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		92D1AF0DE30657AAD86482CA /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
@@ -251,6 +261,7 @@
 		20349AB212EAB4FB5F21D959 /* UI */ = {
 			isa = PBXGroup;
 			children = (
+				88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */,
 				EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */,
 			);
 			name = UI;
@@ -271,6 +282,7 @@
 		3936BDEE64D16E6C4C85B3DD /* Products */ = {
 			isa = PBXGroup;
 			children = (
+				0B750F1634E250256AF3FEB6 /* Listless iOS UI Tests.xctest */,
 				C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */,
 				126108860D7878DDC3BECC4B /* Listless iOS.app */,
 				CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */,
@@ -575,6 +587,24 @@
 			productReference = C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */;
 			productType = "com.apple.product-type.bundle.unit-test";
 		};
+		E550C54CD9C9DD7CAF62B601 /* Listless iOS UI Tests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 54D8A976635B970879C3A083 /* Build configuration list for PBXNativeTarget "Listless iOS UI Tests" */;
+			buildPhases = (
+				8A2F25417981C1E116479B25 /* Sources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				0338860A3D6FCAAFEECA5254 /* PBXTargetDependency */,
+			);
+			name = "Listless iOS UI Tests";
+			packageProductDependencies = (
+			);
+			productName = "Listless iOS UI Tests";
+			productReference = 0B750F1634E250256AF3FEB6 /* Listless iOS UI Tests.xctest */;
+			productType = "com.apple.product-type.bundle.ui-testing";
+		};
 		ECF4D3D0597D0648A1FBC4A4 /* Listless macOS UI Tests */ = {
 			isa = PBXNativeTarget;
 			buildConfigurationList = CD8CF62F04D9EA70CFD04DDD /* Build configuration list for PBXNativeTarget "Listless macOS UI Tests" */;
@@ -622,6 +652,11 @@
 						DevelopmentTeam = 7TD7PZBNXP;
 						ProvisioningStyle = Automatic;
 					};
+					E550C54CD9C9DD7CAF62B601 = {
+						DevelopmentTeam = 7TD7PZBNXP;
+						ProvisioningStyle = Automatic;
+						TestTargetID = 34A03D42B91730DEAC2EBD8E;
+					};
 					ECF4D3D0597D0648A1FBC4A4 = {
 						DevelopmentTeam = 7TD7PZBNXP;
 						ProvisioningStyle = Automatic;
@@ -644,6 +679,7 @@
 			projectRoot = "";
 			targets = (
 				34A03D42B91730DEAC2EBD8E /* Listless iOS */,
+				E550C54CD9C9DD7CAF62B601 /* Listless iOS UI Tests */,
 				D533FDEDCE6DFCA2E8CB70F5 /* Listless iOS Unit Tests */,
 				0FB4F07A37999BBC6DFE4DBB /* Listless macOS */,
 				ECF4D3D0597D0648A1FBC4A4 /* Listless macOS UI Tests */,
@@ -799,6 +835,14 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		8A2F25417981C1E116479B25 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				239F975836FD432A5FF04036 /* ListlessiOSUITests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		D959A967B1BB3E9246C006D7 /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -815,6 +859,11 @@
 /* End PBXSourcesBuildPhase section */
 
 /* Begin PBXTargetDependency section */
+		0338860A3D6FCAAFEECA5254 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 34A03D42B91730DEAC2EBD8E /* Listless iOS */;
+			targetProxy = EE50B14FCB0F89292F1E2A01 /* PBXContainerItemProxy */;
+		};
 		03DCFDF6E769DBF0DBC470F6 /* PBXTargetDependency */ = {
 			isa = PBXTargetDependency;
 			target = 34A03D42B91730DEAC2EBD8E /* Listless iOS */;
@@ -907,6 +956,24 @@
 			};
 			name = Release;
 		};
+		A59B52CB6CD91C01F164C0F6 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.ios.uitests;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_TARGET_NAME = "Listless iOS";
+			};
+			name = Debug;
+		};
 		A5A377EA0FE470803E2B6BA1 /* Release */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -1023,6 +1090,24 @@
 			};
 			name = Debug;
 		};
+		C8A7D13CC2AAF9DEA65CA25E /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.ios.uitests;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_TARGET_NAME = "Listless iOS";
+			};
+			name = Release;
+		};
 		CF2E8B9803D045F7F301E05E /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -1206,6 +1291,15 @@
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationName = Debug;
 		};
+		54D8A976635B970879C3A083 /* Build configuration list for PBXNativeTarget "Listless iOS UI Tests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				A59B52CB6CD91C01F164C0F6 /* Debug */,
+				C8A7D13CC2AAF9DEA65CA25E /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
 		A549D68557611D588CB834B2 /* Build configuration list for PBXNativeTarget "Listless watchOS" */ = {
 			isa = XCConfigurationList;
 			buildConfigurations = (

diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless iOS.xcscheme

@@ -69,6 +69,18 @@
                ReferencedContainer = "container:Listless.xcodeproj">
             </BuildableReference>
          </TestableReference>
+         <TestableReference
+            skipped = "NO"
+            parallelizable = "YES"
+            testExecutionOrdering = "random">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "E550C54CD9C9DD7CAF62B601"
+               BuildableName = "Listless iOS UI Tests.xctest"
+               BlueprintName = "Listless iOS UI Tests"
+               ReferencedContainer = "container:Listless.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
       </Testables>
       <CommandLineArguments>
       </CommandLineArguments>

diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift

@@ -11,9 +11,11 @@ struct TappableTextField: UIViewRepresentable {
     let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
     var returnKeyType: UIReturnKeyType = .done
     var onContentChange: ((String) -> Void)? = nil
+    var uiAccessibilityIdentifier: String? = nil
 
     func makeUIView(context: Context) -> UITextView {
         let textView = UITextView()
+        textView.accessibilityIdentifier = uiAccessibilityIdentifier
         textView.delegate = context.coordinator
         textView.font = TaskRowMetrics.bodyUIK
         textView.backgroundColor = .clear
@@ -54,6 +56,7 @@ struct TappableTextField: UIViewRepresentable {
             textView.returnKeyType = returnKeyType
             textView.reloadInputViews()
         }
+        textView.accessibilityIdentifier = uiAccessibilityIdentifier
         textView.isEditable = !isCompleted && !isDragging
         textView.isSelectable = !isCompleted && !isDragging
         if let placeholder = textView.viewWithTag(100) as? UILabel {
@@ -65,7 +68,8 @@ struct TappableTextField: UIViewRepresentable {
         let proposedWidth = proposal.width ?? uiView.bounds.width
         let width = proposedWidth > 0 ? proposedWidth : (uiView.window?.bounds.width ?? 0)
         guard width > 0 else { return nil }
-        return uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
+        let fitted = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
+        return CGSize(width: width, height: fitted.height)
     }
 
     func makeCoordinator() -> Coordinator {

diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift

@@ -295,7 +295,8 @@ struct TaskListView: View, TaskListViewProtocol {
                         }
                     }
                 },
-                returnKeyType: .done
+                returnKeyType: .done,
+                uiAccessibilityIdentifier: "draft-row-prepend"
             )
             .focused($focusedFieldBinding, equals: .task(draftPrependRowID))
             .frame(maxWidth: .infinity, alignment: .leading)
@@ -361,7 +362,8 @@ struct TaskListView: View, TaskListViewProtocol {
                     },
                     returnKeyType: draftTitle.trimmingCharacters(
                         in: .whitespacesAndNewlines
-                    ).isEmpty ? .done : .next
+                    ).isEmpty ? .done : .next,
+                    uiAccessibilityIdentifier: "draft-row-append"
                 )
                 .focused($focusedFieldBinding, equals: .task(draftAppendRowID))
                 .frame(maxWidth: .infinity, alignment: .leading)

diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift

@@ -71,6 +71,8 @@ struct TaskRowView: View {
                     .font(.system(size: 17))
             }
             .buttonStyle(.borderless)
+            .accessibilityIdentifier("task-checkbox")
+            .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
 
             TappableTextField(
                 text: $editingTitle,
@@ -89,7 +91,8 @@ struct TaskRowView: View {
                 onContentChange: { newTitle in
                     guard !task.isCompleted else { return }
                     onTitleChange(task, newTitle)
-                }
+                },
+                uiAccessibilityIdentifier: "task-text-\(taskID.uuidString)"
             )
             .focused($focusedField, equals: .task(taskID))
             .frame(maxWidth: .infinity, alignment: .leading)

diff --git a/Scripts/test-ios-ui.sh b/Scripts/test-ios-ui.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+set -euo pipefail
+
+xcodebuild test \
+  -scheme "Listless iOS" \
+  -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' \
+  -only-testing:"Listless iOS UI Tests" \
+  2>&1

diff --git a/Tests/UI/ListlessiOSUITests.swift b/Tests/UI/ListlessiOSUITests.swift

@@ -0,0 +1,169 @@
+import XCTest
+
+final class ListlessiOSUITests: XCTestCase {
+    var app: XCUIApplication!
+
+    override func setUpWithError() throws {
+        continueAfterFailure = false
+        app = XCUIApplication()
+        app.launchArguments = ["UI_TESTING"]
+        app.launch()
+    }
+
+    override func tearDownWithError() throws {
+        app.terminate()
+    }
+
+    // MARK: - Helpers
+
+    /// The "Pull down to create" empty state label.
+    var emptyStateLabel: XCUIElement {
+        app.staticTexts["Pull down to create"]
+    }
+
+    /// The main scroll view area; tapping empty space here creates a draft task.
+    var taskListScrollView: XCUIElement {
+        app.scrollViews["task-list-scrollview"]
+    }
+
+    /// The draft row text view that appears after tapping empty space.
+    /// TappableTextField wraps UITextView, so XCUITest sees it as a textView.
+    var draftTextField: XCUIElement {
+        app.textViews.matching(identifier: "draft-row-append").firstMatch
+    }
+
+    /// Returns the text view for a committed task with the given title.
+    func taskText(_ title: String) -> XCUIElement {
+        app.textViews.matching(
+            NSPredicate(format: "identifier BEGINSWITH 'task-text-' AND value == %@", title)
+        ).firstMatch
+    }
+
+    /// Creates a task by tapping empty space to reveal the draft field,
+    /// typing a title, and pressing Return.
+    func createTask(_ title: String) {
+        let textView = draftTextField
+        if !textView.exists {
+            taskListScrollView.tap()
+            if !textView.waitForExistence(timeout: 2) {
+                XCTFail("Draft text view should appear after tapping empty space")
+                return
+            }
+        }
+        textView.tap()
+        textView.typeText(title + "\n")
+    }
+
+    /// Returns the Nth checkbox button (0-indexed).
+    func taskCheckbox(at index: Int) -> XCUIElement {
+        app.buttons.matching(identifier: "task-checkbox").element(boundBy: index)
+    }
+
+    /// Exits editing mode. After createTask, the new draft text view is
+    /// focused. Dismiss it by tapping the scroll view background (which
+    /// calls handleBackgroundTap to commit/dismiss the empty draft).
+    func exitEditingMode() {
+        let draft = draftTextField
+        if draft.exists {
+            draft.typeText("\n")
+        }
+        // Tap background to deselect
+        taskListScrollView.tap()
+    }
+
+    // MARK: - Empty State
+
+    func testLaunchShowsEmptyState() {
+        XCTAssertTrue(
+            emptyStateLabel.waitForExistence(timeout: 2),
+            "Empty state label should be visible on launch"
+        )
+    }
+
+    func testEmptyStateDisappearsAfterCreatingTask() {
+        createTask("First item")
+        exitEditingMode()
+        XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a task")
+    }
+
+    // MARK: - Task Creation
+
+    func testCreateTaskViaTap() {
+        createTask("Buy groceries")
+        exitEditingMode()
+        XCTAssertTrue(
+            taskText("Buy groceries").waitForExistence(timeout: 2),
+            "Task should appear with the typed title"
+        )
+    }
+
+    func testReturnChainsNewTask() {
+        createTask("First item")
+        XCTAssertTrue(
+            draftTextField.waitForExistence(timeout: 2),
+            "New draft text view should appear after Return"
+        )
+    }
+
+    func testCreateMultipleTasks() {
+        createTask("Alpha")
+        createTask("Bravo")
+        createTask("Charlie")
+        exitEditingMode()
+
+        XCTAssertTrue(taskText("Alpha").waitForExistence(timeout: 2))
+        XCTAssertTrue(taskText("Bravo").exists)
+        XCTAssertTrue(taskText("Charlie").exists)
+    }
+
+    func testEmptyTaskDeletedOnCommit() {
+        taskListScrollView.tap()
+        XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
+        draftTextField.typeText("\n")
+        XCTAssertTrue(
+            emptyStateLabel.waitForExistence(timeout: 2),
+            "Empty state should reappear when empty task is discarded"
+        )
+    }
+
+    // MARK: - Task Completion
+
+    func testCompleteTaskViaCheckbox() {
+        createTask("Finish report")
+        exitEditingMode()
+
+        let checkbox = taskCheckbox(at: 0)
+        XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
+        XCTAssertEqual(checkbox.value as? String, "circle")
+        checkbox.tap()
+
+        let completed = app.buttons.matching(
+            NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+        ).firstMatch
+        XCTAssertTrue(
+            completed.waitForExistence(timeout: 3),
+            "Checkbox should show checkmark after tapping"
+        )
+    }
+
+    func testUncompleteTask() {
+        createTask("Finish report")
+        exitEditingMode()
+
+        taskCheckbox(at: 0).tap()
+
+        let completed = app.buttons.matching(
+            NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+        ).firstMatch
+        XCTAssertTrue(completed.waitForExistence(timeout: 3))
+        completed.tap()
+
+        let uncompleted = app.buttons.matching(
+            NSPredicate(format: "identifier == 'task-checkbox' AND value == 'circle'")
+        ).firstMatch
+        XCTAssertTrue(
+            uncompleted.waitForExistence(timeout: 3),
+            "Checkbox should revert to circle after uncompleting"
+        )
+    }
+}

diff --git a/project.yml b/project.yml

@@ -176,11 +176,27 @@ targets:
       BUNDLE_LOADER: $(TEST_HOST)
       GENERATE_INFOPLIST_FILE: YES
 
+  Listless iOS UI Tests:
+    type: bundle.ui-testing
+    platform: iOS
+    sources:
+      - path: Tests/UI
+        includes:
+          - "ListlessiOSUITests.swift"
+    dependencies:
+      - target: Listless iOS
+    settings:
+      PRODUCT_BUNDLE_IDENTIFIER: net.inqk.listless.ios.uitests
+      CODE_SIGN_STYLE: Automatic
+      GENERATE_INFOPLIST_FILE: YES
+
   Listless macOS UI Tests:
     type: bundle.ui-testing
     platform: macOS
     sources:
-      - Tests/UI
+      - path: Tests/UI
+        includes:
+          - "ListlessMacUITests.swift"
     dependencies:
       - target: Listless macOS
     settings:
@@ -208,6 +224,9 @@ schemes:
         - name: Listless iOS Unit Tests
           parallelizable: true
           randomExecutionOrder: true
+        - name: Listless iOS UI Tests
+          parallelizable: true
+          randomExecutionOrder: true
     profile:
       config: Release
     analyze: