← All repositories

listless

A simple list app for Apple platforms

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


commit da94b8b712a6b8494c65c16681b3b421be04f327
parent 7c31167dbea9ffaaa2fd6e9f8d46f96ff207c105
Author: Michael Camilleri <[email protected]>
Date:   Sat, 14 Mar 2026 08:45:29 +0900

Add UI tests for macOS

This commit adds UI tests for the macOS version of Listless. Similar
tests for the iOS version of Listless are planned.

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

Diffstat

MListless.xcodeproj/project.pbxproj | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 12++++++++++++
MListless/Sync/PersistenceController.swift | 16+++++++++++-----
MListlessMac/ListlessMacApp.swift | 14++------------
MListlessMac/Views/TaskListView.swift | 2++
AScripts/test-macos-ui.sh | 8++++++++
DTests/UI/.gitkeep | 0
ATests/UI/ListlessMacUITests.swift | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 15+++++++++++++++

9 files changed, 391 insertions(+), 17 deletions(-)

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

@@ -45,6 +45,7 @@
 		5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; };
 		5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */; };
+		5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */; };
 		614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
 		642151C8EEA34DAD76C49FA1 /* TaskListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */; };
 		65E97DE8C190E9E9B71EC356 /* ListlessWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE7F4637B4F4C1FF4BE160B /* ListlessWatchApp.swift */; };
@@ -123,6 +124,13 @@
 			remoteGlobalIDString = 34A03D42B91730DEAC2EBD8E;
 			remoteInfo = "Listless iOS";
 		};
+		A6710416A995E8ECC4AAE65F /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3256C2BF8F1DAF371DA32120 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 0FB4F07A37999BBC6DFE4DBB;
+			remoteInfo = "Listless macOS";
+		};
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXCopyFilesBuildPhase section */
@@ -199,6 +207,7 @@
 		C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
+		CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
 		D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListViewProtocol.swift; sourceTree = "<group>"; };
 		D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullGestures.swift"; sourceTree = "<group>"; };
@@ -213,6 +222,7 @@
 		E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
 		E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandBridge.swift; sourceTree = "<group>"; };
 		E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
+		EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacUITests.swift; sourceTree = "<group>"; };
 		F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundClickMonitor.swift; sourceTree = "<group>"; };
 		F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
 		F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
@@ -238,6 +248,15 @@
 			path = Helpers;
 			sourceTree = "<group>";
 		};
+		20349AB212EAB4FB5F21D959 /* UI */ = {
+			isa = PBXGroup;
+			children = (
+				EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */,
+			);
+			name = UI;
+			path = Tests/UI;
+			sourceTree = "<group>";
+		};
 		21CCBC7FC4A2D6E15D47B4D1 /* ListlessWatch */ = {
 			isa = PBXGroup;
 			children = (
@@ -254,6 +273,7 @@
 			children = (
 				C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */,
 				126108860D7878DDC3BECC4B /* Listless iOS.app */,
+				CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */,
 				B88DC6E36FA41DCB6CEB9647 /* Listless macOS Unit Tests.xctest */,
 				5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */,
 				C6812E535A24C599C28F9278 /* Listless watchOS.app */,
@@ -423,6 +443,7 @@
 				D5B197AFF26144948D032299 /* ListlessMac */,
 				21CCBC7FC4A2D6E15D47B4D1 /* ListlessWatch */,
 				D98A7EE79F8E29297555B801 /* Support */,
+				20349AB212EAB4FB5F21D959 /* UI */,
 				F63E6E98ABFB43E1B32686B4 /* Unit */,
 				3936BDEE64D16E6C4C85B3DD /* Products */,
 			);
@@ -554,6 +575,24 @@
 			productReference = C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */;
 			productType = "com.apple.product-type.bundle.unit-test";
 		};
+		ECF4D3D0597D0648A1FBC4A4 /* Listless macOS UI Tests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = CD8CF62F04D9EA70CFD04DDD /* Build configuration list for PBXNativeTarget "Listless macOS UI Tests" */;
+			buildPhases = (
+				3EB6FB5DD3E22789C6D9059C /* Sources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				56F7D0BF0FAA18615E95FB41 /* PBXTargetDependency */,
+			);
+			name = "Listless macOS UI Tests";
+			packageProductDependencies = (
+			);
+			productName = "Listless macOS UI Tests";
+			productReference = CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */;
+			productType = "com.apple.product-type.bundle.ui-testing";
+		};
 /* End PBXNativeTarget section */
 
 /* Begin PBXProject section */
@@ -583,6 +622,11 @@
 						DevelopmentTeam = 7TD7PZBNXP;
 						ProvisioningStyle = Automatic;
 					};
+					ECF4D3D0597D0648A1FBC4A4 = {
+						DevelopmentTeam = 7TD7PZBNXP;
+						ProvisioningStyle = Automatic;
+						TestTargetID = 0FB4F07A37999BBC6DFE4DBB;
+					};
 				};
 			};
 			buildConfigurationList = CAACA40A09D5F78ECE7A0EDF /* Build configuration list for PBXProject "Listless" */;
@@ -602,6 +646,7 @@
 				34A03D42B91730DEAC2EBD8E /* Listless iOS */,
 				D533FDEDCE6DFCA2E8CB70F5 /* Listless iOS Unit Tests */,
 				0FB4F07A37999BBC6DFE4DBB /* Listless macOS */,
+				ECF4D3D0597D0648A1FBC4A4 /* Listless macOS UI Tests */,
 				7F0B17D1EC9FD4A80BC99002 /* Listless macOS Unit Tests */,
 				9BDC1B2175AB9CE26790448D /* Listless watchOS */,
 			);
@@ -650,6 +695,14 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		3EB6FB5DD3E22789C6D9059C /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		409D108909CBEC2F69B56D0E /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -767,6 +820,11 @@
 			target = 34A03D42B91730DEAC2EBD8E /* Listless iOS */;
 			targetProxy = 6F7E207AE10E1516EF8A683C /* PBXContainerItemProxy */;
 		};
+		56F7D0BF0FAA18615E95FB41 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 0FB4F07A37999BBC6DFE4DBB /* Listless macOS */;
+			targetProxy = A6710416A995E8ECC4AAE65F /* PBXContainerItemProxy */;
+		};
 		59112A75EB31AFE05E360567 /* PBXTargetDependency */ = {
 			isa = PBXTargetDependency;
 			target = 9BDC1B2175AB9CE26790448D /* Listless watchOS */;
@@ -780,6 +838,24 @@
 /* End PBXTargetDependency section */
 
 /* Begin XCBuildConfiguration section */
+		0488A078966C9DE69E8BD039 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				COMBINE_HIDPI_IMAGES = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.macos.uitests;
+				SDKROOT = macosx;
+				TEST_TARGET_NAME = "Listless macOS";
+			};
+			name = Release;
+		};
 		05B5A346110EC651C700E86E /* Release */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -1055,6 +1131,24 @@
 			};
 			name = Debug;
 		};
+		DDE3BBA838D521B70330B9AE /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				COMBINE_HIDPI_IMAGES = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = net.inqk.listless.macos.uitests;
+				SDKROOT = macosx;
+				TEST_TARGET_NAME = "Listless macOS";
+			};
+			name = Debug;
+		};
 		F32B53087C3BB2F10323145F /* Release */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -1139,6 +1233,15 @@
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationName = Debug;
 		};
+		CD8CF62F04D9EA70CFD04DDD /* Build configuration list for PBXNativeTarget "Listless macOS UI Tests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				DDE3BBA838D521B70330B9AE /* Debug */,
+				0488A078966C9DE69E8BD039 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
 		D6218056DB7B3D1C0ACF1443 /* Build configuration list for PBXNativeTarget "Listless macOS Unit Tests" */ = {
 			isa = XCConfigurationList;
 			buildConfigurations = (

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

@@ -67,6 +67,18 @@
                ReferencedContainer = "container:Listless.xcodeproj">
             </BuildableReference>
          </TestableReference>
+         <TestableReference
+            skipped = "NO"
+            parallelizable = "YES"
+            testExecutionOrdering = "random">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "ECF4D3D0597D0648A1FBC4A4"
+               BuildableName = "Listless macOS UI Tests.xctest"
+               BlueprintName = "Listless macOS UI Tests"
+               ReferencedContainer = "container:Listless.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
       </Testables>
    </TestAction>
    <LaunchAction

diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift

@@ -50,7 +50,7 @@ private final class UpdatedAtMergePolicy: NSMergePolicy {
 final class PersistenceController {
     static let shared = PersistenceController()
 
-    let container: NSPersistentCloudKitContainer
+    let container: NSPersistentContainer
     let syncMonitor: CloudKitSyncMonitor
 
     var viewContext: NSManagedObjectContext {
@@ -58,12 +58,18 @@ final class PersistenceController {
     }
 
     init(inMemory: Bool = false) {
-        container = NSPersistentCloudKitContainer(name: "Listless")
         syncMonitor = CloudKitSyncMonitor()
 
         if inMemory {
-            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
+            // Use a plain NSPersistentContainer (no CloudKit) with a unique
+            // temporary store so each launch is fully isolated.
+            let tempURL = FileManager.default.temporaryDirectory
+                .appendingPathComponent(UUID().uuidString)
+                .appendingPathExtension("sqlite")
+            container = NSPersistentContainer(name: "Listless")
+            container.persistentStoreDescriptions.first?.url = tempURL
         } else {
+            container = NSPersistentCloudKitContainer(name: "Listless")
             // Configure CloudKit sync
             guard let description = container.persistentStoreDescriptions.first else {
                 fatalError("Failed to retrieve persistent store description")
@@ -96,8 +102,8 @@ final class PersistenceController {
         container.viewContext.automaticallyMergesChangesFromParent = true
         container.viewContext.mergePolicy = UpdatedAtMergePolicy()
 
-        if !inMemory {
-            syncMonitor.startMonitoring(container: container)
+        if !inMemory, let cloudContainer = container as? NSPersistentCloudKitContainer {
+            syncMonitor.startMonitoring(container: cloudContainer)
         }
 
     }

diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift

@@ -1,5 +1,5 @@
-import SwiftUI
 import AppKit
+import SwiftUI
 
 private enum MenuSelectors {
     static let showSettingsWindow = Selector(("showSettingsWindow:"))
@@ -26,22 +26,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
     }
 
     func applicationDidFinishLaunching(_ notification: Notification) {
-        // When the system launches the app for background work (e.g. CloudKit
-        // sync), there is no Apple Event. Only open a window for user-initiated
-        // launches; terminate otherwise so the app doesn't linger.
-        guard
-            let event = NSAppleEventManager.shared().currentAppleEvent,
-            event.eventID == kAEOpenApplication
-        else {
-            NSApp.terminate(nil)
-            return
-        }
-
         NSWindow.allowsAutomaticWindowTabbing = false
         installMainMenu()
         openNewWindow()
     }
 
+
     func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
         if !flag {
             openNewWindow()

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

@@ -326,6 +326,7 @@ struct TaskListView: View, TaskListViewProtocol {
                                 .stroke(accentColor.opacity(0.40), lineWidth: 2)
                         }
                     }
+                    .accessibilityIdentifier("draft-row-append")
                     .id(draftAppendRowID)
                 }
 
@@ -394,6 +395,7 @@ struct TaskListView: View, TaskListViewProtocol {
                     .font(.subheadline)
                     .foregroundStyle(.secondary)
                     .allowsHitTesting(false)
+                    .accessibilityIdentifier("empty-state-label")
             }
         }
         .focusable()

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

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

diff --git a/Tests/UI/.gitkeep b/Tests/UI/.gitkeep

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

@@ -0,0 +1,238 @@
+import XCTest
+
+final class ListlessMacUITests: 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 "Click to create" empty state label.
+    var emptyStateLabel: XCUIElement {
+        app.staticTexts["Click to create"]
+    }
+
+    /// The draft row text field that appears after Cmd+N.
+    var draftTextField: XCUIElement {
+        app.textFields["draft-row-append"]
+    }
+
+    /// Returns the text field for a committed task with the given title.
+    /// Committed tasks expose as TextField with identifier "task-text-\(title)".
+    func taskText(_ title: String) -> XCUIElement {
+        app.textFields["task-text-\(title)"]
+    }
+
+    /// Creates a task by typing into the draft field and pressing Return.
+    /// If no draft field exists yet, presses Cmd+N to create one.
+    func createTask(_ title: String) {
+        let textField = draftTextField
+        if !textField.exists {
+            app.typeKey("n", modifierFlags: .command)
+            if !textField.waitForExistence(timeout: 2) {
+                XCTFail("Draft text field should appear after Cmd+N")
+                return
+            }
+        }
+        textField.click()
+        textField.typeText(title)
+        textField.typeKey(.return, modifierFlags: [])
+    }
+
+    /// Returns the Nth checkbox button (0-indexed).
+    func taskCheckbox(at index: Int) -> XCUIElement {
+        app.buttons.matching(identifier: "task-checkbox").element(boundBy: index)
+    }
+
+    /// Enters navigation mode by pressing Escape, then navigates to the task at
+    /// the given position (0-indexed from the top) using arrow keys.
+    func navigateToTask(at index: Int) {
+        app.typeKey(.escape, modifierFlags: [])
+        for _ in 0...index {
+            app.typeKey(.downArrow, modifierFlags: [])
+        }
+    }
+
+    // MARK: - Empty State
+
+    func testLaunchShowsEmptyState() {
+        XCTAssertTrue(
+            emptyStateLabel.waitForExistence(timeout: 2),
+            "Empty state label should be visible on launch"
+        )
+    }
+
+    func testEmptyStateDisappearsAfterCreatingTask() {
+        createTask("First item")
+        app.typeKey(.escape, modifierFlags: [])
+        XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a task")
+    }
+
+    // MARK: - Task Creation
+
+    func testCreateTaskViaMenuShortcut() {
+        createTask("Buy groceries")
+        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 field should appear after Return"
+        )
+    }
+
+    func testCreateMultipleTasks() {
+        createTask("Alpha")
+        createTask("Bravo")
+        createTask("Charlie")
+        app.typeKey(.escape, modifierFlags: [])
+
+        XCTAssertTrue(taskText("Alpha").waitForExistence(timeout: 2))
+        XCTAssertTrue(taskText("Bravo").exists)
+        XCTAssertTrue(taskText("Charlie").exists)
+    }
+
+    func testEmptyTaskDeletedOnCommit() {
+        app.typeKey("n", modifierFlags: .command)
+        XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
+        app.typeKey(.escape, modifierFlags: [])
+        XCTAssertTrue(
+            emptyStateLabel.waitForExistence(timeout: 2),
+            "Empty state should reappear when empty task is discarded"
+        )
+    }
+
+    // MARK: - Task Completion
+
+    func testCompleteTaskViaCheckbox() {
+        createTask("Finish report")
+        app.typeKey(.escape, modifierFlags: [])
+
+        let checkbox = taskCheckbox(at: 0)
+        XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
+        XCTAssertEqual(checkbox.value as? String, "circle")
+        checkbox.click()
+
+        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 clicking"
+        )
+    }
+
+    func testUncompleteTask() {
+        createTask("Finish report")
+        app.typeKey(.escape, modifierFlags: [])
+
+        taskCheckbox(at: 0).click()
+
+        let completed = app.buttons.matching(
+            NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+        ).firstMatch
+        XCTAssertTrue(completed.waitForExistence(timeout: 3))
+        completed.click()
+
+        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"
+        )
+    }
+
+    // MARK: - Task Deletion
+
+    func testDeleteTaskViaBackspace() {
+        createTask("Delete me")
+        navigateToTask(at: 0)
+        app.typeKey(.delete, modifierFlags: [])
+        XCTAssertTrue(
+            emptyStateLabel.waitForExistence(timeout: 2),
+            "Empty state should reappear after deleting the only task"
+        )
+    }
+
+    func testArrowKeyNavigationThenDelete() {
+        createTask("Keep me")
+        createTask("Delete me")
+        navigateToTask(at: 1)
+        app.typeKey(.delete, modifierFlags: [])
+
+        XCTAssertTrue(taskText("Keep me").waitForExistence(timeout: 2), "First task should remain")
+        XCTAssertFalse(taskText("Delete me").exists, "Second task should be deleted")
+    }
+
+    // MARK: - Reordering
+
+    func testMoveTaskUp() {
+        createTask("Alpha")
+        createTask("Bravo")
+        navigateToTask(at: 1)
+        app.typeKey(.upArrow, modifierFlags: .command)
+
+        // Use firstMatch to avoid ambiguity if draft row duplicates an identifier
+        let bravo = app.textFields.matching(identifier: "task-text-Bravo").firstMatch
+        let alpha = app.textFields.matching(identifier: "task-text-Alpha").firstMatch
+        XCTAssertTrue(bravo.waitForExistence(timeout: 2))
+        XCTAssertTrue(alpha.exists)
+        XCTAssertLessThan(
+            bravo.frame.minY, alpha.frame.minY,
+            "Bravo should appear above Alpha after moving up"
+        )
+    }
+
+    func testMoveTaskDown() {
+        createTask("Alpha")
+        createTask("Bravo")
+        navigateToTask(at: 0)
+        app.typeKey(.downArrow, modifierFlags: .command)
+
+        let alpha = app.textFields.matching(identifier: "task-text-Alpha").firstMatch
+        let bravo = app.textFields.matching(identifier: "task-text-Bravo").firstMatch
+        XCTAssertTrue(alpha.waitForExistence(timeout: 2))
+        XCTAssertTrue(bravo.exists)
+        XCTAssertGreaterThan(
+            alpha.frame.minY, bravo.frame.minY,
+            "Alpha should appear below Bravo after moving down"
+        )
+    }
+
+    // MARK: - Clear Completed
+
+    func testClearCompleted() {
+        createTask("Done task")
+        app.typeKey(.escape, modifierFlags: [])
+
+        taskCheckbox(at: 0).click()
+
+        let completed = app.buttons.matching(
+            NSPredicate(format: "identifier == 'task-checkbox' AND value == 'checkmark.circle.fill'")
+        ).firstMatch
+        XCTAssertTrue(completed.waitForExistence(timeout: 3))
+
+        app.menuBars.menuBarItems["Edit"].click()
+        app.menuBars.menuBarItems["Edit"].menus.menuItems["Clear Completed"].click()
+
+        XCTAssertTrue(
+            emptyStateLabel.waitForExistence(timeout: 3),
+            "Empty state should reappear after clearing the only completed task"
+        )
+    }
+}

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

@@ -176,6 +176,18 @@ targets:
       BUNDLE_LOADER: $(TEST_HOST)
       GENERATE_INFOPLIST_FILE: YES
 
+  Listless macOS UI Tests:
+    type: bundle.ui-testing
+    platform: macOS
+    sources:
+      - Tests/UI
+    dependencies:
+      - target: Listless macOS
+    settings:
+      PRODUCT_BUNDLE_IDENTIFIER: net.inqk.listless.macos.uitests
+      CODE_SIGN_STYLE: Automatic
+      GENERATE_INFOPLIST_FILE: YES
+
 schemes:
   Listless iOS:
     build:
@@ -222,6 +234,9 @@ schemes:
         - name: Listless macOS Unit Tests
           parallelizable: true
           randomExecutionOrder: true
+        - name: Listless macOS UI Tests
+          parallelizable: true
+          randomExecutionOrder: true
     profile:
       config: Release
     analyze: