listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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: