ListlessMacUITests.swift (16143B)
1 import CoreGraphics 2 import XCTest 3 4 final class ListlessMacUITests: XCTestCase { 5 var app: XCUIApplication! 6 7 override func setUpWithError() throws { 8 continueAfterFailure = false 9 app = XCUIApplication() 10 app.launchArguments = ["UI_TESTING"] 11 app.launch() 12 } 13 14 override func tearDownWithError() throws { 15 app.terminate() 16 } 17 18 // MARK: - Helpers 19 20 /// The "Click to create" empty state label. 21 var emptyStateLabel: XCUIElement { 22 app.staticTexts["Click to create"] 23 } 24 25 /// The draft row text field that appears after Cmd+N. 26 var draftTextField: XCUIElement { 27 app.textFields["draft-row-append"] 28 } 29 30 /// Returns the text field for a committed item with the given title. 31 func itemText(_ title: String) -> XCUIElement { 32 app.textFields.matching( 33 NSPredicate(format: "identifier BEGINSWITH 'item-text-' AND value == %@", title) 34 ).firstMatch 35 } 36 37 /// Creates a item by typing into the draft field and pressing Return. 38 /// If no draft field exists yet, presses Cmd+N to create one. 39 func createItem(_ title: String) { 40 let textField = draftTextField 41 if !textField.exists { 42 app.typeKey("n", modifierFlags: .command) 43 if !textField.waitForExistence(timeout: 2) { 44 XCTFail("Draft text field should appear after Cmd+N") 45 return 46 } 47 } 48 textField.click() 49 textField.typeText(title) 50 textField.typeKey(.return, modifierFlags: []) 51 } 52 53 /// Returns the Nth checkbox button (0-indexed). 54 func itemCheckbox(at index: Int) -> XCUIElement { 55 app.buttons.matching(identifier: "item-checkbox").element(boundBy: index) 56 } 57 58 /// Enters navigation mode by pressing Escape, then navigates to the item at 59 /// the given position (0-indexed from the top) using arrow keys. 60 func navigateToItem(at index: Int) { 61 app.typeKey(.escape, modifierFlags: []) 62 for _ in 0...index { 63 app.typeKey(.downArrow, modifierFlags: []) 64 } 65 } 66 67 /// Performs a Command+Click on the row containing the given item title. 68 /// Uses CGEvent mouse events with `.maskCommand` so the app sees the 69 /// modifier via `NSApp.currentEvent?.modifierFlags`. Clicks in the 70 /// row's left padding area (before the checkbox) so the tap gesture 71 /// fires rather than the text field or checkbox. 72 func cmdClickRow(withText title: String) { 73 let textField = itemText(title) 74 XCTAssertTrue(textField.waitForExistence(timeout: 2)) 75 // Offset to the left of the text field, into the row's 16pt left padding. 76 let coord = textField.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5)) 77 .withOffset(CGVector(dx: -40, dy: 0)) 78 let point = coord.screenPoint 79 80 let src = CGEventSource(stateID: .combinedSessionState) 81 82 let mouseDown = CGEvent( 83 mouseEventSource: src, mouseType: .leftMouseDown, 84 mouseCursorPosition: point, mouseButton: .left 85 )! 86 mouseDown.flags = .maskCommand 87 mouseDown.post(tap: .cgSessionEventTap) 88 usleep(50_000) 89 90 let mouseUp = CGEvent( 91 mouseEventSource: src, mouseType: .leftMouseUp, 92 mouseCursorPosition: point, mouseButton: .left 93 )! 94 mouseUp.flags = .maskCommand 95 mouseUp.post(tap: .cgSessionEventTap) 96 usleep(200_000) 97 } 98 99 // MARK: - Empty State 100 101 func testLaunchShowsEmptyState() { 102 XCTAssertTrue( 103 emptyStateLabel.waitForExistence(timeout: 2), 104 "Empty state label should be visible on launch" 105 ) 106 } 107 108 func testEmptyStateDisappearsAfterCreatingItem() { 109 createItem("First item") 110 app.typeKey(.escape, modifierFlags: []) 111 XCTAssertFalse(emptyStateLabel.exists, "Empty state should disappear after creating a item") 112 } 113 114 // MARK: - Item Creation 115 116 func testCmdNFocusesDraftField() { 117 app.typeKey("n", modifierFlags: .command) 118 let textField = draftTextField 119 XCTAssertTrue( 120 textField.waitForExistence(timeout: 2), 121 "Draft text field should appear after Cmd+N" 122 ) 123 // Type directly without clicking — verifies the field has focus 124 textField.typeText("Focused item") 125 textField.typeKey(.return, modifierFlags: []) 126 XCTAssertTrue( 127 itemText("Focused item").waitForExistence(timeout: 2), 128 "Typing without clicking should commit the item if draft field has focus" 129 ) 130 } 131 132 func testCreateItemViaMenuShortcut() { 133 createItem("Buy groceries") 134 XCTAssertTrue( 135 itemText("Buy groceries").waitForExistence(timeout: 2), 136 "Item should appear with the typed title" 137 ) 138 } 139 140 func testReturnChainsNewItem() { 141 createItem("First item") 142 XCTAssertTrue( 143 draftTextField.waitForExistence(timeout: 2), 144 "New draft text field should appear after Return" 145 ) 146 } 147 148 func testCreateMultipleItems() { 149 createItem("Alpha") 150 createItem("Bravo") 151 createItem("Charlie") 152 app.typeKey(.escape, modifierFlags: []) 153 154 XCTAssertTrue(itemText("Alpha").waitForExistence(timeout: 2)) 155 XCTAssertTrue(itemText("Bravo").exists) 156 XCTAssertTrue(itemText("Charlie").exists) 157 } 158 159 func testEmptyItemDeletedOnCommit() { 160 app.typeKey("n", modifierFlags: .command) 161 XCTAssertTrue(draftTextField.waitForExistence(timeout: 2)) 162 app.typeKey(.escape, modifierFlags: []) 163 XCTAssertTrue( 164 emptyStateLabel.waitForExistence(timeout: 2), 165 "Empty state should reappear when empty item is discarded" 166 ) 167 } 168 169 // MARK: - Item Completion 170 171 func testCompleteItemViaCheckbox() { 172 createItem("Finish report") 173 app.typeKey(.escape, modifierFlags: []) 174 175 let checkbox = itemCheckbox(at: 0) 176 XCTAssertTrue(checkbox.waitForExistence(timeout: 2)) 177 XCTAssertEqual(checkbox.value as? String, "circle") 178 checkbox.click() 179 180 let completed = app.buttons.matching( 181 NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'") 182 ).firstMatch 183 XCTAssertTrue( 184 completed.waitForExistence(timeout: 3), 185 "Checkbox should show checkmark after clicking" 186 ) 187 } 188 189 func testUncompleteItem() { 190 createItem("Finish report") 191 app.typeKey(.escape, modifierFlags: []) 192 193 itemCheckbox(at: 0).click() 194 195 let completed = app.buttons.matching( 196 NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'") 197 ).firstMatch 198 XCTAssertTrue(completed.waitForExistence(timeout: 3)) 199 completed.click() 200 201 let uncompleted = app.buttons.matching( 202 NSPredicate(format: "identifier == 'item-checkbox' AND value == 'circle'") 203 ).firstMatch 204 XCTAssertTrue( 205 uncompleted.waitForExistence(timeout: 3), 206 "Checkbox should revert to circle after uncompleting" 207 ) 208 } 209 210 // MARK: - Item Deletion 211 212 func testDeleteItemViaBackspace() { 213 createItem("Delete me") 214 navigateToItem(at: 0) 215 app.typeKey(.delete, modifierFlags: []) 216 XCTAssertTrue( 217 emptyStateLabel.waitForExistence(timeout: 2), 218 "Empty state should reappear after deleting the only item" 219 ) 220 } 221 222 func testArrowKeyNavigationThenDelete() { 223 createItem("Keep me") 224 createItem("Delete me") 225 navigateToItem(at: 1) 226 app.typeKey(.delete, modifierFlags: []) 227 228 XCTAssertTrue(itemText("Keep me").waitForExistence(timeout: 2), "First item should remain") 229 XCTAssertFalse(itemText("Delete me").exists, "Second item should be deleted") 230 } 231 232 // MARK: - Reordering 233 234 func testMoveItemUp() { 235 createItem("Alpha") 236 createItem("Bravo") 237 navigateToItem(at: 1) 238 app.typeKey(.upArrow, modifierFlags: .command) 239 240 let bravo = itemText("Bravo") 241 let alpha = itemText("Alpha") 242 XCTAssertTrue(bravo.waitForExistence(timeout: 2)) 243 XCTAssertTrue(alpha.exists) 244 XCTAssertLessThan( 245 bravo.frame.minY, alpha.frame.minY, 246 "Bravo should appear above Alpha after moving up" 247 ) 248 } 249 250 func testMoveItemDown() { 251 createItem("Alpha") 252 createItem("Bravo") 253 navigateToItem(at: 0) 254 app.typeKey(.downArrow, modifierFlags: .command) 255 256 let alpha = itemText("Alpha") 257 let bravo = itemText("Bravo") 258 XCTAssertTrue(alpha.waitForExistence(timeout: 2)) 259 XCTAssertTrue(bravo.exists) 260 XCTAssertGreaterThan( 261 alpha.frame.minY, bravo.frame.minY, 262 "Alpha should appear below Bravo after moving down" 263 ) 264 } 265 266 // MARK: - Select All 267 268 func testSelectAllThenDelete() { 269 createItem("Alpha") 270 createItem("Bravo") 271 createItem("Charlie") 272 navigateToItem(at: 0) 273 app.typeKey("a", modifierFlags: .command) 274 app.typeKey(.delete, modifierFlags: []) 275 XCTAssertTrue( 276 emptyStateLabel.waitForExistence(timeout: 2), 277 "All items should be deleted after Select All + Delete" 278 ) 279 } 280 281 func testSelectAllIncludesCompletedItems() { 282 createItem("Active item") 283 createItem("Done item") 284 app.typeKey(.escape, modifierFlags: []) 285 286 // Complete the second item 287 itemCheckbox(at: 1).click() 288 let completed = app.buttons.matching( 289 NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'") 290 ).firstMatch 291 XCTAssertTrue(completed.waitForExistence(timeout: 3)) 292 293 // Navigate to first item, then select all and delete 294 navigateToItem(at: 0) 295 app.typeKey("a", modifierFlags: .command) 296 app.typeKey(.delete, modifierFlags: []) 297 XCTAssertTrue( 298 emptyStateLabel.waitForExistence(timeout: 2), 299 "Both active and completed items should be deleted after Select All + Delete" 300 ) 301 } 302 303 // MARK: - Clear Completed 304 305 func testClearCompleted() { 306 createItem("Done item") 307 app.typeKey(.escape, modifierFlags: []) 308 309 itemCheckbox(at: 0).click() 310 311 let completed = app.buttons.matching( 312 NSPredicate(format: "identifier == 'item-checkbox' AND value == 'checkmark.circle.fill'") 313 ).firstMatch 314 XCTAssertTrue(completed.waitForExistence(timeout: 3)) 315 316 app.menuBars.menuBarItems["Edit"].click() 317 app.menuBars.menuBarItems["Edit"].menus.menuItems["Clear Completed"].click() 318 319 XCTAssertTrue( 320 emptyStateLabel.waitForExistence(timeout: 3), 321 "Empty state should reappear after clearing the only completed item" 322 ) 323 } 324 325 // MARK: - Shift+Arrow Selection 326 327 func testShiftDownExtendsSelection() { 328 createItem("Alpha") 329 createItem("Bravo") 330 createItem("Charlie") 331 navigateToItem(at: 0) 332 app.typeKey(.downArrow, modifierFlags: .shift) 333 app.typeKey(.downArrow, modifierFlags: .shift) 334 // All three should be selected; delete removes them all. 335 app.typeKey(.delete, modifierFlags: []) 336 XCTAssertTrue( 337 emptyStateLabel.waitForExistence(timeout: 2), 338 "All three items should be deleted after Shift+Down range select" 339 ) 340 } 341 342 func testShiftUpContractsSelection() { 343 createItem("Alpha") 344 createItem("Bravo") 345 createItem("Charlie") 346 navigateToItem(at: 0) 347 // Extend down to select Alpha, Bravo, Charlie 348 app.typeKey(.downArrow, modifierFlags: .shift) 349 app.typeKey(.downArrow, modifierFlags: .shift) 350 // Contract back up: deselect Charlie, then Bravo 351 app.typeKey(.upArrow, modifierFlags: .shift) 352 app.typeKey(.upArrow, modifierFlags: .shift) 353 // Only Alpha should be selected now. 354 app.typeKey(.delete, modifierFlags: []) 355 XCTAssertTrue(itemText("Bravo").waitForExistence(timeout: 2), "Bravo should remain") 356 XCTAssertTrue(itemText("Charlie").exists, "Charlie should remain") 357 XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted") 358 } 359 360 func testSelectAllThenShiftDown() { 361 createItem("Alpha") 362 createItem("Bravo") 363 createItem("Charlie") 364 navigateToItem(at: 0) 365 // Select all via Cmd+A 366 app.typeKey("a", modifierFlags: .command) 367 // Shift+Down should be a no-op (cursor already at last item). 368 // All three should still be selected. 369 app.typeKey(.downArrow, modifierFlags: .shift) 370 app.typeKey(.delete, modifierFlags: []) 371 XCTAssertTrue( 372 emptyStateLabel.waitForExistence(timeout: 2), 373 "All items should still be selected after Shift+Down at end" 374 ) 375 } 376 377 // MARK: - Cmd+Click Selection 378 379 func testCmdClickDeselectsFromRange() { 380 createItem("Alpha") 381 createItem("Bravo") 382 createItem("Charlie") 383 navigateToItem(at: 0) 384 // Extend selection to all three 385 app.typeKey(.downArrow, modifierFlags: .shift) 386 app.typeKey(.downArrow, modifierFlags: .shift) 387 // Cmd+Click Bravo to deselect it 388 cmdClickRow(withText: "Bravo") 389 app.typeKey(.delete, modifierFlags: []) 390 391 XCTAssertTrue( 392 itemText("Bravo").waitForExistence(timeout: 2), 393 "Bravo should remain (was deselected by Cmd+Click)" 394 ) 395 XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted") 396 XCTAssertFalse(itemText("Charlie").exists, "Charlie should be deleted") 397 } 398 399 func testCmdClickAddsToSelection() { 400 createItem("Alpha") 401 createItem("Bravo") 402 createItem("Charlie") 403 navigateToItem(at: 0) 404 // Cmd+Click Charlie to add it to selection 405 cmdClickRow(withText: "Charlie") 406 app.typeKey(.delete, modifierFlags: []) 407 408 XCTAssertTrue( 409 itemText("Bravo").waitForExistence(timeout: 2), 410 "Bravo should remain (was not selected)" 411 ) 412 XCTAssertFalse(itemText("Alpha").exists, "Alpha should be deleted") 413 XCTAssertFalse(itemText("Charlie").exists, "Charlie should be deleted") 414 } 415 416 func testShiftUpAfterCmdClickDeselect() { 417 createItem("Delta") 418 createItem("Echo") 419 createItem("Foxtrot") 420 createItem("Golf") 421 navigateToItem(at: 0) 422 // Select Delta through Golf 423 app.typeKey(.downArrow, modifierFlags: .shift) 424 app.typeKey(.downArrow, modifierFlags: .shift) 425 app.typeKey(.downArrow, modifierFlags: .shift) 426 // Cmd+Click Echo to deselect: {Delta, Foxtrot, Golf} 427 cmdClickRow(withText: "Echo") 428 // Shift+Up contracts from cursor end: removes Golf → {Delta, Foxtrot} 429 app.typeKey(.upArrow, modifierFlags: .shift) 430 app.typeKey(.delete, modifierFlags: []) 431 432 XCTAssertTrue(itemText("Echo").waitForExistence(timeout: 2), "Echo should remain") 433 XCTAssertTrue(itemText("Golf").exists, "Golf should remain") 434 XCTAssertFalse(itemText("Delta").exists, "Delta should be deleted") 435 XCTAssertFalse(itemText("Foxtrot").exists, "Foxtrot should be deleted") 436 } 437 438 func testSelectAllAfterCmdClick() { 439 createItem("Alpha") 440 createItem("Bravo") 441 createItem("Charlie") 442 navigateToItem(at: 0) 443 app.typeKey(.downArrow, modifierFlags: .shift) 444 app.typeKey(.downArrow, modifierFlags: .shift) 445 // Cmd+Click Bravo to create discontinuous selection {Alpha, Charlie} 446 cmdClickRow(withText: "Bravo") 447 // Cmd+A should select all, clearing any discontinuous state 448 app.typeKey("a", modifierFlags: .command) 449 app.typeKey(.delete, modifierFlags: []) 450 451 XCTAssertTrue( 452 emptyStateLabel.waitForExistence(timeout: 2), 453 "All items should be deleted after Select All" 454 ) 455 } 456 }