-
-
Notifications
You must be signed in to change notification settings - Fork 991
Assistant design improvements + V2 API #3327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
17c4a25
ca5930b
db1b944
c6bb211
fc5c7e3
12c80c1
2450052
a98b1e8
0fb5ac9
a935eda
55e52e3
41d7f13
e78e189
79e2a31
4c4d09b
0309a32
555e8e3
ef892ae
257f8e5
d26f7ff
801b1ab
db9a6d5
72f3f02
f06202e
bcddba6
7483eed
30b2262
6aa4894
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| // SPDX-FileCopyrightText: Nextcloud GmbH | ||
| // SPDX-FileCopyrightText: 2025 Iva Horn | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| import XCTest | ||
|
|
||
| /// | ||
| /// User interface tests for the download limits management on shares. | ||
| /// | ||
| @MainActor | ||
| final class AssistantUITests: BaseUIXCTestCase { | ||
| let taskInputCreated = "TestTaskCreated" + UUID().uuidString | ||
| let taskInputRetried = "TestTaskRetried" + UUID().uuidString | ||
| let taskInputToEdit = "TestTaskToEdit" + UUID().uuidString | ||
| let taskInputDeleted = "TestTaskDeleted" + UUID().uuidString | ||
|
|
||
| // MARK: - Lifecycle | ||
|
|
||
| override func setUp() async throws { | ||
| try await super.setUp() | ||
| continueAfterFailure = false | ||
|
|
||
| // Handle alerts presented by the system. | ||
| addUIInterruptionMonitor(withDescription: "Allow Notifications", for: "Allow") | ||
| addUIInterruptionMonitor(withDescription: "Save Password", for: "Not Now") | ||
|
|
||
| // Launch the app. | ||
| app = XCUIApplication() | ||
| app.launchArguments = ["UI_TESTING"] | ||
| app.launch() | ||
|
|
||
| try await logIn() | ||
|
|
||
| // Set up test backend communication. | ||
| backend = UITestBackend() | ||
|
|
||
| try await backend.assertCapability(true, capability: \.assistant) | ||
| } | ||
|
|
||
| /// | ||
| /// Leads to the Assistant screen. | ||
| /// | ||
| private func goToAssistant() { | ||
| let button = app.tabBars["Tab Bar"].buttons["More"] | ||
| guard button.await() else { return } | ||
| button.tap() | ||
|
|
||
| let talkStaticText = app.tables.staticTexts["Assistant"] | ||
| talkStaticText.tap() | ||
| } | ||
|
|
||
| private func createTask(input: String) { | ||
| app.navigationBars["Assistant"].buttons["CreateButton"].tap() | ||
|
|
||
| let inputTextEditor = app.textViews["InputTextEditor"] | ||
| inputTextEditor.await() | ||
| inputTextEditor.typeText(input) | ||
| app.navigationBars["New Free text to text prompt task"].buttons["Create"].tap() | ||
| } | ||
|
|
||
| private func retryTask() { | ||
| let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) | ||
| cell.staticTexts[taskInputRetried].press(forDuration: 2); | ||
|
|
||
| let retryButton = app.buttons["TaskRetryContextMenu"] | ||
| XCTAssertTrue(retryButton.waitForExistence(timeout: 2), "Edit button not found in context menu") | ||
| retryButton.tap() | ||
| } | ||
|
|
||
| private func editTask() { | ||
| let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) | ||
| cell.staticTexts[taskInputToEdit].press(forDuration: 2); | ||
|
|
||
| let editButton = app.buttons["TaskEditContextMenu"] | ||
| XCTAssertTrue(editButton.waitForExistence(timeout: 2), "Edit button not found in context menu") | ||
| editButton.tap() | ||
|
|
||
| app.textViews["InputTextEditor"].typeText("Edited") | ||
| app.navigationBars["Edit Free text to text prompt task"].buttons["Edit"].tap() | ||
| } | ||
|
|
||
| private func deleteTask() { | ||
| let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) | ||
| cell.staticTexts[taskInputDeleted].press(forDuration: 2); | ||
|
|
||
| let deleteButton = app.buttons["TaskDeleteContextMenu"] | ||
| XCTAssertTrue(deleteButton.waitForExistence(timeout: 2), "Edit button not found in context menu") | ||
| deleteButton.tap() | ||
|
|
||
| app.sheets.scrollViews.otherElements.buttons["Delete"].tap() | ||
| } | ||
|
|
||
| // MARK: - Tests | ||
|
|
||
| func testCreateAssistantTask() async throws { | ||
| goToAssistant() | ||
|
|
||
| createTask(input: taskInputCreated) | ||
|
|
||
| pullToRefresh() | ||
|
Comment on lines
+98
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When running against localhost, a short delay is needed in between, otherwise pull to refresh will fail because the cell has not appeared yet in the visible area. |
||
|
|
||
| try await aMoment() | ||
|
|
||
| let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) | ||
| XCTAssert(cell.staticTexts[taskInputCreated].exists) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fails for me when testing against tech preview because "an error occurred" when creating the task, so there is no cell to find. |
||
| } | ||
|
|
||
| func testRetryAssistantTask() async throws { | ||
| goToAssistant() | ||
|
|
||
| createTask(input: taskInputRetried) | ||
|
|
||
| retryTask() | ||
|
|
||
| pullToRefresh() | ||
|
|
||
| try await aMoment() | ||
|
|
||
| let matchingElements = app.collectionViews.cells.staticTexts.matching(identifier: taskInputRetried) | ||
| print(app.collectionViews.staticTexts.debugDescription) | ||
| XCTAssertEqual(matchingElements.count, 2, "Expected 2 elements") | ||
| } | ||
|
|
||
| func testEditAssistantTask() async throws { | ||
| goToAssistant() | ||
|
|
||
| createTask(input: taskInputToEdit) | ||
|
|
||
| editTask() | ||
|
|
||
| pullToRefresh() | ||
|
|
||
| try await aMoment() | ||
|
|
||
| let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) | ||
| XCTAssert(cell.staticTexts[taskInputToEdit + "Edited"].exists) | ||
| } | ||
|
|
||
| func testDeleteAssistantTask() async throws { | ||
| goToAssistant() | ||
|
|
||
| createTask(input: taskInputDeleted) | ||
|
|
||
| deleteTask() | ||
|
|
||
| pullToRefresh() | ||
|
|
||
| try await aMoment() | ||
|
|
||
| let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) | ||
| XCTAssert(!cell.staticTexts[taskInputDeleted].exists) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| // SPDX-FileCopyrightText: Nextcloud GmbH | ||
| // SPDX-FileCopyrightText: 2025 Milen Pivchev | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| import Foundation | ||
| import XCTest | ||
|
|
||
| @MainActor | ||
| class BaseUIXCTestCase: XCTestCase { | ||
| var app: XCUIApplication! | ||
|
|
||
| /// | ||
| /// The Nextcloud server API abstraction object. | ||
| /// | ||
| var backend: UITestBackend! | ||
|
|
||
| /// | ||
| /// Generic convenience method to define user interface interruption monitors. | ||
| /// | ||
| /// This is called every time an alert from outside the app's user interface is presented (in example system prompt about saving a password). | ||
| /// Then the button is tapped defined by the given `label`. | ||
| /// | ||
| /// - Parameters: | ||
| /// - description: The human readable description for the monitor to create. | ||
| /// - label: The localized text on the alert action to tap. | ||
| /// | ||
| /// | ||
| func addUIInterruptionMonitor(withDescription description: String, for label: String) { | ||
| addUIInterruptionMonitor(withDescription: description) { alert in | ||
| let button = alert.buttons[label] | ||
|
|
||
| if button.exists { | ||
| button.tap() | ||
| return true | ||
| } | ||
|
|
||
| return false | ||
| } | ||
| } | ||
|
|
||
| /// | ||
| /// Let the current `Task` rest for 2 seconds. | ||
| /// | ||
| /// Some asynchronous background activities like the follow up request to define a download limit have no effect on the visible user interface. | ||
| /// Hence their outcome can only be assumed after a brief period of time. | ||
| /// | ||
| func aSmallMoment() async throws { | ||
| try await Task.sleep(for: .seconds(2)) | ||
| } | ||
|
|
||
| /// | ||
| /// Let the current `Task` rest for ``TestConstants/controlExistenceTimeout``. | ||
| /// | ||
| /// Some asynchronous background activities like the follow up request to define a download limit have no effect on the visible user interface. | ||
| /// Hence their outcome can only be assumed after a brief period of time. | ||
| /// | ||
| func aMoment() async throws { | ||
| try await Task.sleep(for: .seconds(TestConstants.controlExistenceTimeout)) | ||
| } | ||
|
|
||
| /// | ||
| /// Automation of the sign-in, if required. | ||
| /// | ||
| /// | ||
| func logIn() async throws { | ||
| guard app.buttons["login"].exists else { | ||
| return | ||
| } | ||
|
|
||
| app.buttons["login"].tap() | ||
|
|
||
| let serverAddressTextField = app.textFields["serverAddress"].firstMatch | ||
| guard serverAddressTextField.await() else { return } | ||
|
|
||
| serverAddressTextField.tap() | ||
| serverAddressTextField.typeText(TestConstants.server) | ||
|
|
||
| app.buttons["submitServerAddress"].tap() | ||
|
|
||
| let webView = app.webViews.firstMatch | ||
|
|
||
| guard webView.await() else { | ||
| throw UITestError.waitForExistence(webView) | ||
| } | ||
|
|
||
| let loginButton = webView.buttons["Log in"] | ||
|
|
||
| if loginButton.await() { | ||
| loginButton.tap() | ||
| } | ||
|
|
||
| let usernameTextField = webView.textFields.firstMatch | ||
|
|
||
| if usernameTextField.await() { | ||
| guard usernameTextField.await() else { return } | ||
| usernameTextField.tap() | ||
| usernameTextField.typeText(TestConstants.username) | ||
|
|
||
| let passwordSecureTextField = webView.secureTextFields.firstMatch | ||
| passwordSecureTextField.tap() | ||
| passwordSecureTextField.typeText(TestConstants.password) | ||
|
|
||
| webView.buttons.firstMatch.tap() | ||
| } | ||
|
|
||
| try await aSmallMoment() | ||
|
|
||
| let grantButton = webView.buttons["Grant access"] | ||
|
|
||
| guard grantButton.await() else { | ||
| throw UITestError.waitForExistence(grantButton) | ||
| } | ||
|
|
||
| grantButton.tap() | ||
| grantButton.awaitInexistence() | ||
|
|
||
| app.buttons["accountSwitcher"].await() | ||
|
|
||
| try await aSmallMoment() | ||
| } | ||
|
|
||
| /// | ||
| /// Pull to refresh on the first found collection view to reveal the new file on the server. | ||
| /// | ||
| func pullToRefresh(file: StaticString = #file, line: UInt = #line) { | ||
| let cell = app.collectionViews.firstMatch.staticTexts.firstMatch | ||
|
|
||
| guard cell.exists else { | ||
| XCTFail("Apparently no collection view cell is visible!", file: file, line: line) | ||
| return | ||
| } | ||
|
|
||
| let start = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 0)) | ||
| let finish = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 20)) | ||
|
|
||
| start.press(forDuration: 0.2, thenDragTo: finish) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The subscript is invalid. Issuing
p app.navigationBars.allElementsBoundByAccessibilityElement.map(\.identifier)in the debugger prints:This resembles the navigation bar title "New ContextAgent task" in the sheet of the assistant.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interestingly, this is different when testing against the server on
localhost: