listless

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

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: