Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
17c4a25
WIP
mpivchev Feb 13, 2025
ca5930b
Merge branch 'develop' of https://github.com/nextcloud/ios into nc-as…
mpivchev Feb 13, 2025
db1b944
refactor
mpivchev Feb 13, 2025
c6bb211
WIP
mpivchev Feb 14, 2025
fc5c7e3
WIP
mpivchev Feb 14, 2025
12c80c1
WIP
mpivchev Feb 18, 2025
2450052
WIP
mpivchev Feb 19, 2025
a98b1e8
WIP
mpivchev Feb 19, 2025
0fb5ac9
a7532a22 Fix polling timer not stopping
mpivchev Feb 19, 2025
a935eda
Change CI code
mpivchev Feb 20, 2025
55e52e3
UI tests
mpivchev Feb 20, 2025
41d7f13
WIP
mpivchev Feb 21, 2025
e78e189
WIP
mpivchev Feb 21, 2025
79e2a31
Merge branch 'develop' of https://github.com/nextcloud/ios into nc-as…
mpivchev Feb 24, 2025
4c4d09b
Refactor date
mpivchev Feb 24, 2025
0309a32
Merge branch 'develop' of https://github.com/nextcloud/ios into nc-as…
mpivchev Feb 24, 2025
555e8e3
Refacgtor
mpivchev Feb 24, 2025
ef892ae
Refactor
mpivchev Feb 24, 2025
257f8e5
WIP
mpivchev Feb 24, 2025
d26f7ff
Merge branch 'develop' of https://github.com/nextcloud/ios into nc-as…
mpivchev Feb 28, 2025
801b1ab
Merge branch 'develop' of https://github.com/nextcloud/ios into nc-as…
mpivchev Mar 4, 2025
db9a6d5
PR changes
mpivchev Mar 5, 2025
72f3f02
Update Tests/NextcloudUITests/AssistantUITests.swift
mpivchev Mar 6, 2025
f06202e
Merge branch 'develop' of https://github.com/nextcloud/ios into nc-as…
mpivchev Mar 10, 2025
bcddba6
Merge branch 'develop' of https://github.com/nextcloud/ios into nc-as…
mpivchev Mar 11, 2025
7483eed
PR fixes
mpivchev Mar 11, 2025
30b2262
PR fixes 2
mpivchev Mar 11, 2025
6aa4894
PR fixes 3
mpivchev Mar 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 41 additions & 17 deletions Nextcloud.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Share/NCShareExtension+DataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ extension NCShareExtension: UICollectionViewDataSource {
cell.imageItem.image = NCImageCache.shared.getFolder(account: metadata.account)
}

cell.labelInfo.text = utility.dateDiff(metadata.date as Date)
cell.labelInfo.text = utility.getRelativeDateTitle(metadata.date as Date)

let lockServerUrl = utilityFileSystem.stringAppendServerUrl(metadata.serverUrl, addFileName: metadata.fileName)
let tableDirectory = self.database.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, lockServerUrl))
Expand Down
153 changes: 153 additions & 0 deletions Tests/NextcloudUITests/AssistantUITests.swift
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()
Copy link
Contributor

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:

([String]) 2 values {
  [0] = "More"
  [1] = "New ContextAgent task"
}

This resembles the navigation bar title "New ContextAgent task" in the sheet of the assistant.

Copy link
Contributor

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:

([String]) 2 values {
  [0] = "More"
  [1] = "New Free text to text prompt task"
}

}

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
Copy link
Contributor

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

The 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)
}
}
138 changes: 138 additions & 0 deletions Tests/NextcloudUITests/BaseUIXCTestCase.swift
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)
}
}
Loading
Loading