From 98d9f5a6ea2861a77c3a522217b0c406ba4c16eb Mon Sep 17 00:00:00 2001 From: joshua kaunert <44586402+jkaunert@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:52:02 -0500 Subject: [PATCH 1/3] Stabilize project navigator UI tests --- CodeEdit/AppDelegate.swift | 48 ++++++++++-- .../ProjectNavigatorToolbarBottom.swift | 5 +- CodeEditUITests/App.swift | 14 ++++ ...rojectNavigatorFileManagementUITests.swift | 42 ++++++---- .../ProjectNavigatorUITests.swift | 17 +++- CodeEditUITests/ProjectPath.swift | 4 + CodeEditUITests/Query.swift | 77 ++++++++++++++++++- 7 files changed, 175 insertions(+), 32 deletions(-) diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 124e7fb4b5..403d19c7d9 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -38,18 +38,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } for index in 0.. URL { + let baseURL = FileManager.default.temporaryDirectory + .appending(path: "CodeEditUITests") + let url = baseURL.appending(path: id) + + do { + if FileManager.default.fileExists(atPath: baseURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: baseURL) + } + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } catch { + logger.error("Failed to create UI test workspace: \(error.localizedDescription, privacy: .public)") + } + + return url + } +#endif + func applicationWillTerminate(_ aNotification: Notification) { } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift index bb1e44fb8e..c43f8d98d7 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -132,14 +132,13 @@ struct ProjectNavigatorToolbarBottom: View { alert.runModal() } } - } label: {} - .background { + } label: { Image(systemName: "plus") .accessibilityHidden(true) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) - .frame(maxWidth: 18, alignment: .center) + .frame(width: 18, alignment: .center) .opacity(activeState == .inactive ? 0.45 : 1) .accessibilityLabel("Add Folder or File") .accessibilityIdentifier("addButton") diff --git a/CodeEditUITests/App.swift b/CodeEditUITests/App.swift index 855380fc84..e8a1cf82a7 100644 --- a/CodeEditUITests/App.swift +++ b/CodeEditUITests/App.swift @@ -24,6 +24,20 @@ enum App { return (application, tempDirURL) } + // Launches CodeEdit with an app-writable directory that CodeEdit creates before opening. + static func launchWithAppWritableTempDir() -> (XCUIApplication, String) { + let tempDirID = appWritableTempProjectID() + let application = XCUIApplication() + application.launchArguments = [ + "-ApplePersistenceIgnoreState", + "YES", + "--codeedit-uitest-open-temp-workspace", + tempDirID + ] + application.launch() + return (application, tempDirID) + } + static func launch() -> XCUIApplication { let application = XCUIApplication() application.launchArguments = ["-ApplePersistenceIgnoreState", "YES"] diff --git a/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift index 4335ed8694..b091d801c6 100644 --- a/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift +++ b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift @@ -17,7 +17,11 @@ final class ProjectNavigatorFileManagementUITests: XCTestCase { override func setUp() async throws { // MainActor required for async compatibility which is required to make this method throwing try await MainActor.run { - (app, path) = try App.launchWithTempDir() + if name.contains("testCreateNewFiles") { + (app, path) = App.launchWithAppWritableTempDir() + } else { + (app, path) = try App.launchWithTempDir() + } window = Query.getWindow(app) XCTAssertTrue(window.exists, "Window not found") @@ -81,29 +85,39 @@ final class ProjectNavigatorFileManagementUITests: XCTestCase { func testCreateNewFiles() throws { // Add a few files with the navigator button for idx in 0..<5 { - let addButton = window.popUpButtons["addButton"] + let previousRowCount = Query.Navigator.getRows(navigator).count + let addButton = Query.Navigator.getAddButton(window) + XCTAssertTrue(addButton.waitForExistence(timeout: 2.0), "Add button not found") addButton.click() - let addMenu = addButton.menus.firstMatch - addMenu.menuItems["Add File"].click() - let selectedRows = Query.Navigator.getSelectedRows(navigator) - guard selectedRows.firstMatch.waitForExistence(timeout: 0.5) else { - XCTFail("No new selected rows appeared") - return - } + let addFileMenuItem = addButton.menuItems["Add File"] + XCTAssertTrue(addFileMenuItem.waitForExistence(timeout: 2.0), "Add File menu item not found") + addFileMenuItem.click() let title = idx > 0 ? "untitled\(idx)" : "untitled" + XCTAssertTrue( + Query.Navigator.waitForRowCount(navigator, greaterThan: previousRowCount, timeout: 5.0), + "No new navigator row appeared after adding \(title)" + ) + + guard let newFileRow = Query.Navigator.waitForProjectNavigatorRow( + fileTitle: title, + navigator, + timeout: 5.0 + ) else { + XCTFail("\(title) did not appear in the navigator") + return + } - let newFileRow = selectedRows.firstMatch XCTAssertEqual(newFileRow.descendants(matching: .textField).firstMatch.value as? String, title) let tabBar = Query.Window.getTabBar(window) - XCTAssertTrue(tabBar.exists) - let readmeTab = Query.TabBar.getTab(labeled: title, tabBar) - XCTAssertTrue(readmeTab.exists) + XCTAssertTrue(tabBar.waitForExistence(timeout: 2.0)) + let newFileTab = Query.TabBar.getTab(labeled: title, tabBar) + XCTAssertTrue(newFileTab.waitForExistence(timeout: 2.0)) let newFileEditor = Query.Window.getFirstEditor(window) - XCTAssertTrue(newFileEditor.exists) + XCTAssertTrue(newFileEditor.waitForExistence(timeout: 2.0)) XCTAssertNotNil(newFileEditor.value as? String) } } diff --git a/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorUITests.swift b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorUITests.swift index baac35964a..68ad6a984f 100644 --- a/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorUITests.swift +++ b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorUITests.swift @@ -27,6 +27,7 @@ final class ProjectNavigatorUITests: XCTestCase { // Open the README.md let readmeRow = Query.Navigator.getProjectNavigatorRow(fileTitle: "README.md", navigator) + XCTAssertTrue(readmeRow.waitForExistence(timeout: 5.0)) XCTAssertFalse(Query.Navigator.rowContainsDisclosureIndicator(readmeRow), "File has disclosure indicator") readmeRow.click() @@ -42,19 +43,27 @@ final class ProjectNavigatorUITests: XCTestCase { let rowCount = navigator.descendants(matching: .outlineRow).count // Open a folder - let codeEditFolderRow = Query.Navigator.getProjectNavigatorRow(fileTitle: "CodeEdit", index: 1, navigator) - XCTAssertTrue(codeEditFolderRow.exists) + let codeEditFolderRow = Query.Navigator.getLastProjectNavigatorRow(fileTitle: "CodeEdit", navigator) + XCTAssertTrue(codeEditFolderRow.waitForExistence(timeout: 5.0)) + let folderDisclosureIndicator = Query.Navigator.disclosureIndicatorForRow(codeEditFolderRow) XCTAssertTrue( - Query.Navigator.rowContainsDisclosureIndicator(codeEditFolderRow), + folderDisclosureIndicator.waitForExistence(timeout: 2.0), "Folder doesn't have disclosure indicator" ) - let folderDisclosureIndicator = Query.Navigator.disclosureIndicatorForRow(codeEditFolderRow) folderDisclosureIndicator.click() + XCTAssertTrue( + Query.Navigator.waitForRowCount(navigator, greaterThan: rowCount, timeout: 2.0), + "No new rows were loaded after opening the folder" + ) let newRowCount = navigator.descendants(matching: .outlineRow).count XCTAssertTrue(newRowCount > rowCount, "No new rows were loaded after opening the folder") folderDisclosureIndicator.click() + XCTAssertTrue( + Query.Navigator.waitForRowCount(navigator, equalTo: rowCount, timeout: 2.0), + "Rows were not hidden after closing a folder" + ) let finalRowCount = navigator.descendants(matching: .outlineRow).count XCTAssertTrue(newRowCount > finalRowCount, "Rows were not hidden after closing a folder") XCTAssertEqual(rowCount, finalRowCount, "Different Number of rows loaded") diff --git a/CodeEditUITests/ProjectPath.swift b/CodeEditUITests/ProjectPath.swift index 457d7ddbe2..a8f45a00f2 100644 --- a/CodeEditUITests/ProjectPath.swift +++ b/CodeEditUITests/ProjectPath.swift @@ -36,6 +36,10 @@ func tempProjectPath() throws -> String { return path.path(percentEncoded: false) } +func appWritableTempProjectID() -> String { + makeTempID() +} + func cleanUpTempProjectPaths() throws { let baseDir = FileManager.default.temporaryDirectory.appending(path: "CodeEditUITests") try FileManager.default.removeItem(at: baseDir) diff --git a/CodeEditUITests/Query.swift b/CodeEditUITests/Query.swift index fab5eda77e..324cce023e 100644 --- a/CodeEditUITests/Query.swift +++ b/CodeEditUITests/Query.swift @@ -56,23 +56,94 @@ enum Query { navigator.descendants(matching: .outlineRow) } + static func getAddButton(_ window: XCUIElement) -> XCUIElement { + if window.buttons["addButton"].exists { + return window.buttons["addButton"] + } + + if window.popUpButtons["addButton"].exists { + return window.popUpButtons["addButton"] + } + + return window.descendants(matching: .any).matching(identifier: "addButton").firstMatch + } + static func getSelectedRows(_ navigator: XCUIElement) -> XCUIElementQuery { getRows(navigator).matching(NSPredicate(format: "selected = true")) } - static func getProjectNavigatorRow(fileTitle: String, index: Int = 0, _ navigator: XCUIElement) -> XCUIElement { - return getRows(navigator) + static func getProjectNavigatorRows(fileTitle: String, _ navigator: XCUIElement) -> XCUIElementQuery { + getRows(navigator) .containing(.textField, identifier: "ProjectNavigatorTableViewCell-\(fileTitle)") + } + + static func getProjectNavigatorRow(fileTitle: String, index: Int = 0, _ navigator: XCUIElement) -> XCUIElement { + return getProjectNavigatorRows(fileTitle: fileTitle, navigator) .element(boundBy: index) } + static func waitForProjectNavigatorRow( + fileTitle: String, + index: Int = 0, + _ navigator: XCUIElement, + timeout: TimeInterval + ) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let row = getProjectNavigatorRow(fileTitle: fileTitle, index: index, navigator) + if row.exists { + return row + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + let row = getProjectNavigatorRow(fileTitle: fileTitle, index: index, navigator) + return row.exists ? row : nil + } + + static func getLastProjectNavigatorRow(fileTitle: String, _ navigator: XCUIElement) -> XCUIElement { + let matchingRows = getProjectNavigatorRows(fileTitle: fileTitle, navigator) + return matchingRows.element(boundBy: max(matchingRows.count - 1, 0)) + } + static func disclosureIndicatorForRow(_ row: XCUIElement) -> XCUIElement { - row.descendants(matching: .disclosureTriangle).element + row.descendants(matching: .disclosureTriangle).firstMatch } static func rowContainsDisclosureIndicator(_ row: XCUIElement) -> Bool { disclosureIndicatorForRow(row).exists } + + static func waitForRowCount( + _ navigator: XCUIElement, + greaterThan rowCount: Int, + timeout: TimeInterval + ) -> Bool { + waitForRowCount(navigator, timeout: timeout) { $0 > rowCount } + } + + static func waitForRowCount( + _ navigator: XCUIElement, + equalTo rowCount: Int, + timeout: TimeInterval + ) -> Bool { + waitForRowCount(navigator, timeout: timeout) { $0 == rowCount } + } + + private static func waitForRowCount( + _ navigator: XCUIElement, + timeout: TimeInterval, + predicate: (Int) -> Bool + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if predicate(getRows(navigator).count) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return predicate(getRows(navigator).count) + } } enum TabBar { From a85448746d840ba26286fad2e69fbc4f2c3e053a Mon Sep 17 00:00:00 2001 From: joshua kaunert <44586402+jkaunert@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:51:55 -0500 Subject: [PATCH 2/3] Address Project Navigator UI test review feedback --- CodeEdit/AppDelegate.swift | 4 ++-- CodeEditUITests/ProjectPath.swift | 25 +++++++++++++++++++++++-- CodeEditUITests/Query.swift | 8 -------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 403d19c7d9..ddccb1c40b 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -80,8 +80,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { let url = baseURL.appending(path: id) do { - if FileManager.default.fileExists(atPath: baseURL.path(percentEncoded: false)) { - try FileManager.default.removeItem(at: baseURL) + if FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: url) } try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } catch { diff --git a/CodeEditUITests/ProjectPath.swift b/CodeEditUITests/ProjectPath.swift index a8f45a00f2..c084e5ed02 100644 --- a/CodeEditUITests/ProjectPath.swift +++ b/CodeEditUITests/ProjectPath.swift @@ -41,7 +41,28 @@ func appWritableTempProjectID() -> String { } func cleanUpTempProjectPaths() throws { + let fileManager = FileManager.default let baseDir = FileManager.default.temporaryDirectory.appending(path: "CodeEditUITests") - try FileManager.default.removeItem(at: baseDir) - tempProjectPathIds.removeAll() + var cleanupError: Error? + var remainingIDs = Set() + + for id in tempProjectPathIds { + let path = baseDir.appending(path: id) + guard fileManager.fileExists(atPath: path.path(percentEncoded: false)) else { + continue + } + + do { + try fileManager.removeItem(at: path) + } catch { + cleanupError = cleanupError ?? error + remainingIDs.insert(id) + } + } + + tempProjectPathIds = remainingIDs + + if let cleanupError { + throw cleanupError + } } diff --git a/CodeEditUITests/Query.swift b/CodeEditUITests/Query.swift index 324cce023e..2c832a4443 100644 --- a/CodeEditUITests/Query.swift +++ b/CodeEditUITests/Query.swift @@ -57,14 +57,6 @@ enum Query { } static func getAddButton(_ window: XCUIElement) -> XCUIElement { - if window.buttons["addButton"].exists { - return window.buttons["addButton"] - } - - if window.popUpButtons["addButton"].exists { - return window.popUpButtons["addButton"] - } - return window.descendants(matching: .any).matching(identifier: "addButton").firstMatch } From 159a6ef3aa2f4b0c8ab23b27d7cf98883c78bedf Mon Sep 17 00:00:00 2001 From: joshua kaunert <44586402+jkaunert@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:42:31 -0500 Subject: [PATCH 3/3] Address UI test re-review feedback --- CodeEdit/AppDelegate.swift | 5 +- CodeEditUITests/App.swift | 2 +- ...rojectNavigatorFileManagementUITests.swift | 9 +++ CodeEditUITests/ProjectPath.swift | 57 +++++++++++++++---- 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index ddccb1c40b..a22a76d856 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -85,7 +85,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } catch { - logger.error("Failed to create UI test workspace: \(error.localizedDescription, privacy: .public)") + let path = url.path(percentEncoded: false) + let message = "Failed to create UI test workspace at \(path): \(error.localizedDescription)" + logger.error("\(message, privacy: .public)") + fatalError(message) } return url diff --git a/CodeEditUITests/App.swift b/CodeEditUITests/App.swift index e8a1cf82a7..f9516c5d81 100644 --- a/CodeEditUITests/App.swift +++ b/CodeEditUITests/App.swift @@ -35,7 +35,7 @@ enum App { tempDirID ] application.launch() - return (application, tempDirID) + return (application, appWritableTempProjectPath(id: tempDirID)) } static func launch() -> XCUIApplication { diff --git a/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift index b091d801c6..34fd3fc129 100644 --- a/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift +++ b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift @@ -30,6 +30,15 @@ final class ProjectNavigatorFileManagementUITests: XCTestCase { navigator = Query.Window.getProjectNavigator(window) XCTAssertTrue(navigator.exists, "Navigator not found") XCTAssertEqual(Query.Navigator.getRows(navigator).count, 1, "Found more than just the root file.") + + if name.contains("testCreateNewFiles") { + var isDirectory: ObjCBool = false + XCTAssertTrue( + FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + "App-writable temp directory was not created at \(path ?? "")" + ) + XCTAssertTrue(isDirectory.boolValue, "App-writable temp project path is not a directory") + } } } diff --git a/CodeEditUITests/ProjectPath.swift b/CodeEditUITests/ProjectPath.swift index c084e5ed02..66921ee72d 100644 --- a/CodeEditUITests/ProjectPath.swift +++ b/CodeEditUITests/ProjectPath.swift @@ -18,6 +18,34 @@ func projectPath() -> String { } private var tempProjectPathIds = Set() +private let codeEditAppBundleID = "app.codeedit.CodeEdit" + +private func testRunnerTempProjectURL(id: String) -> URL { + FileManager.default.temporaryDirectory + .appending(path: "CodeEditUITests") + .appending(path: id) +} + +private func userHomeDirectoryOutsideSandbox() -> URL { + let homePath = NSHomeDirectoryForUser(NSUserName()) ?? NSHomeDirectory() + // UI test runners are sandboxed too; strip that container before addressing the app's container. + if let containerRange = homePath.range(of: "/Library/Containers/") { + return URL(fileURLWithPath: String(homePath[.. URL { + // CodeEdit is sandboxed, so app-created temporary workspaces live in the app container. + userHomeDirectoryOutsideSandbox() + .appending(path: "Library") + .appending(path: "Containers") + .appending(path: codeEditAppBundleID) + .appending(path: "Data") + .appending(path: "tmp") + .appending(path: "CodeEditUITests") + .appending(path: id) +} private func makeTempID() -> String { let id = String((0..<10).map { _ in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-".randomElement()! }) @@ -29,9 +57,8 @@ private func makeTempID() -> String { } func tempProjectPath() throws -> String { - let baseDir = FileManager.default.temporaryDirectory.appending(path: "CodeEditUITests") let id = makeTempID() - let path = baseDir.appending(path: id) + let path = testRunnerTempProjectURL(id: id) try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) return path.path(percentEncoded: false) } @@ -40,22 +67,32 @@ func appWritableTempProjectID() -> String { makeTempID() } +func appWritableTempProjectPath(id: String) -> String { + appWritableTempProjectURL(id: id).path(percentEncoded: false) +} + func cleanUpTempProjectPaths() throws { let fileManager = FileManager.default - let baseDir = FileManager.default.temporaryDirectory.appending(path: "CodeEditUITests") var cleanupError: Error? var remainingIDs = Set() for id in tempProjectPathIds { - let path = baseDir.appending(path: id) - guard fileManager.fileExists(atPath: path.path(percentEncoded: false)) else { - continue + let paths = [ + testRunnerTempProjectURL(id: id), + appWritableTempProjectURL(id: id) + ] + var didFailCleanup = false + + for path in paths where fileManager.fileExists(atPath: path.path(percentEncoded: false)) { + do { + try fileManager.removeItem(at: path) + } catch { + cleanupError = cleanupError ?? error + didFailCleanup = true + } } - do { - try fileManager.removeItem(at: path) - } catch { - cleanupError = cleanupError ?? error + if didFailCleanup { remainingIDs.insert(id) } }