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:
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: