Use files instead of UDP for signalling. (#585)

This commit is contained in:
Doug 2023-02-15 14:02:50 +00:00 committed by GitHub
parent 6333de802a
commit 872c911cb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 181 additions and 252 deletions

View File

@ -40,6 +40,10 @@ function_body_length:
warning: 50
error: 100
nesting:
type_level:
warning: 5
custom_rules:
print_deprecation:
regex: "\\b(print)\\b"

View File

@ -72,13 +72,22 @@
"version" : "7.4.1"
}
},
{
"identity" : "kzfilewatchers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/krzysztofzablocki/KZFileWatchers",
"state" : {
"branch" : "master",
"revision" : "d27a9557427d261adccdf4b566acc9d9c0fec6f4"
}
},
{
"identity" : "matrix-analytics-events",
"kind" : "remoteSourceControl",
"location" : "https://github.com/matrix-org/matrix-analytics-events",
"state" : {
"branch" : "main",
"revision" : "4bcc7f566b165cd2d8fde07d23bda77e1d9fbb2d"
"revision" : "94598bd8ef3aa2a3d3848c656c37d5e95b6b1cff",
"version" : "0.4.0"
}
},
{

View File

@ -32,12 +32,15 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default
private var connection: UITestsSignalling.Connection?
private var client: UITestsSignalling.Client?
init(listenForSignals: Bool = false) {
if listenForSignals {
connection = .init()
Task { try await startListening() }
guard listenForSignals else { return }
do {
try startListening()
} catch {
fatalError("Failure setting up signalling: \(error)")
}
}
@ -70,20 +73,18 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
// MARK: - UI Test signalling
/// The cancellable used for UI Tests signalling.
private var signalCancellable: AnyCancellable?
/// Allows the simulation of server responses by listening for signals from UI tests.
private func startListening() async throws {
try await connection?.connect()
private func startListening() throws {
let client = try UITestsSignalling.Client(mode: .app)
Task {
while let connection {
do {
try await handleSignal(connection.receive())
} catch {
connection.disconnect()
self.connection = nil
}
}
signalCancellable = client.signals.sink { [weak self] signal in
Task { try await self?.handleSignal(signal) }
}
self.client = client
}
/// Handles a UI test signal as necessary.
@ -106,7 +107,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
timelineItems.append(incomingItem)
callbacks.send(.updatedTimelineItems)
try? await connection?.send(.success)
try client?.send(.success)
}
/// Prepends the next chunk of items to the `timelineItems` array.
@ -119,6 +120,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
callbacks.send(.updatedTimelineItems)
callbacks.send(.isBackPaginating(false))
try? await connection?.send(.success)
try client?.send(.success)
}
}

View File

@ -14,12 +14,13 @@
// limitations under the License.
//
import Network
import Combine
import KZFileWatchers
import SwiftUI
enum UITestsSignal: String {
/// An internal signal used to bring up the connection.
case connect
/// An internal signal used to indicate that one side of the connection is ready.
case ready
/// Ask the app to back paginate.
case paginate
/// Ask the app to simulate an incoming message.
@ -28,211 +29,127 @@ enum UITestsSignal: String {
case success
}
enum UITestsSignalError: Error {
/// An unknown error occurred.
case unknown
/// Signalling could not be used as is hasn't been enabled.
case disabled
/// The connection was cancelled.
case cancelled
/// The connection hasn't been established.
enum UITestsSignalError: String, LocalizedError {
/// The app client failed to start as the tests client isn't ready.
case testsClientNotReady
/// Failed to send a signal as a connection hasn't been established.
case notConnected
/// Attempted to receive multiple signals at once.
case awaitingAnotherSignal
/// Receiving the next signal timed out.
case timeout
/// A network error occurred.
case nwError(NWError)
/// An unexpected signal was received. This error isn't used internally.
case unexpected
var errorDescription: String? { "UITestsSignalError.\(rawValue)" }
}
enum UITestsSignalling {
/// The Bonjour service name used for the connection. The device name
/// is included to allow UI tests to run on multiple devices simultaneously.
private static let serviceName = "UITestsSignalling \(UIDevice.current.name) (\(Locale.current.identifier))"
/// The Bonjour service type used for the connection.
private static let serviceType = "_signalling._udp."
/// The Bonjour domain used for the connection.
private static let domain = "local."
/// The DispatchQueue used for networking.
private static let queue: DispatchQueue = .main
/// A network listener that can be used in the UI tests runner to create a two-way `Connection` with the app.
class Listener {
/// The underlying network listener.
private let listener: NWListener
/// The established connection. This is stored in case the connection is established
/// before `connection()` is awaited and so the continuation is still `nil`.
private var establishedConnection: Connection?
/// The continuation to call when a connection is established.
private var connectionContinuation: CheckedContinuation<Connection, Error>?
/// Creates a new signalling `Listener` and starts listening.
init() throws {
let service = NWListener.Service(name: UITestsSignalling.serviceName, type: UITestsSignalling.serviceType, domain: UITestsSignalling.domain)
listener = try NWListener(service: service, using: .udp)
listener.newConnectionHandler = { [weak self] nwConnection in
let connection = Connection(nwConnection: nwConnection)
nwConnection.start(queue: UITestsSignalling.queue)
nwConnection.stateUpdateHandler = { state in
switch state {
case .ready:
connection.receiveNextMessage()
self?.establishedConnection = connection
self?.connectionContinuation?.resume(returning: connection)
case .failed(let error):
self?.connectionContinuation?.resume(with: .failure(error))
default:
break
}
}
self?.listener.cancel() // Stop listening for connections when one is discovered.
}
listener.start(queue: UITestsSignalling.queue)
}
/// Returns the negotiated `Connection` as and when it has been established.
func connection() async throws -> Connection {
guard listener.state == .setup else { throw UITestsSignalError.unknown }
if let establishedConnection {
return establishedConnection
}
return try await withCheckedThrowingContinuation { [weak self] continuation in
self?.connectionContinuation = continuation
}
}
/// Stops the listening when a connection hasn't been established.
func cancel() {
listener.cancel()
if let connectionContinuation {
connectionContinuation.resume(throwing: UITestsSignalError.cancelled)
self.connectionContinuation = nil
}
}
}
/// A two-way UDP connection that can be used for signalling between the app and the UI tests runner.
/// A two-way file-based signalling client that can be used to signal between the app and the UI tests runner.
/// The connection should be created as follows:
/// - Create a `Listener` in the UI tests before launching the app. This will automatically start listening for a connection.
/// - With in the App, create a `Connection` and call `connect()` to establish a connection.
/// - Await the `connection()` on the `Listener` when you need to send the signal.
/// - The two `Connection` objects can now be used for two-way signalling.
class Connection {
/// The underlying network connection.
private let connection: NWConnection
/// A continuation to call each time a signal is received.
private var nextMessageContinuation: CheckedContinuation<UITestsSignal, Error>?
/// A task to handle the timeout when receiving a signal.
private var nextMessageTimeoutTask: Task<Void, Never>? {
didSet {
oldValue?.cancel()
}
}
/// - Create a `Client` in `tests` mode in your UI tests before launching the app. It will start listening for signals.
/// - Within the app, create a `Client` in `app` mode. This will check that the tests are ready and echo back that the app is too.
/// - Call `waitForApp()` in the tests when you need to send the signal. This will suspend execution until the app has signalled it is ready.
/// - The two `Client` objects can now be used for two-way signalling.
class Client {
/// The file watcher responsible for receiving signals.
private let fileWatcher: FileWatcher.Local
/// Creates a new signalling `Connection`.
init() {
let endpoint = NWEndpoint.service(name: UITestsSignalling.serviceName,
type: UITestsSignalling.serviceType,
domain: UITestsSignalling.domain,
interface: nil)
connection = NWConnection(to: endpoint, using: .udp)
}
/// The file name used for the connection.
///
/// The device name is included to allow UI tests to run on multiple devices simultaneously.
/// When using parallel execution, each execution will spawn a simulator clone with its own unique name.
private let fileURL = {
let directory = URL(filePath: "/Users/Shared")
let deviceName = (UIDevice.current.name).replacing(" ", with: "-")
return directory.appending(component: "UITestsSignalling-\(deviceName)")
}()
/// Creates a new signalling `Connection` from an established `NWConnection`.
fileprivate init(nwConnection: NWConnection) {
connection = nwConnection
}
/// A mode that defines the behaviour of the client.
enum Mode: String { case app, tests }
/// The mode that the client is using.
let mode: Mode
/// Attempts to establish a connection with a `Listener`.
func connect() async throws {
guard connection.state == .setup else { return }
/// A publisher the will be sent every time a new signal is received.
let signals = PassthroughSubject<UITestsSignal, Never>()
/// Whether or not the client has established a connection.
private(set) var isConnected = false
/// Creates a new signalling `Client`.
init(mode: Mode) throws {
fileWatcher = .init(path: fileURL.path())
self.mode = mode
return try await withCheckedThrowingContinuation { continuation in
connection.start(queue: UITestsSignalling.queue)
connection.stateUpdateHandler = { state in
switch state {
case .ready:
self.receiveNextMessage()
continuation.resume()
Task { try await self.send(.connect) }
case .failed(let error):
continuation.resume(with: .failure(error))
default:
break
}
}
switch mode {
case .tests:
// The tests client is started first and writes to the file saying it is ready.
try rawSignal(.ready).write(to: fileURL, atomically: false, encoding: .utf8)
case .app:
// The app client is started second and checks that there is a ready signal from the tests.
guard try String(contentsOf: fileURL) == "\(Mode.tests):\(UITestsSignal.ready)" else { throw UITestsSignalError.testsClientNotReady }
isConnected = true
// The app client then echoes back to the tests that it is now ready.
try send(.ready)
}
}
/// Stops the connection.
func disconnect() {
connection.cancel()
if let nextMessageContinuation {
nextMessageContinuation.resume(throwing: UITestsSignalError.cancelled)
self.nextMessageContinuation = nil
nextMessageTimeoutTask = nil
}
}
/// Sends a message to the other side of the connection.
func send(_ signal: UITestsSignal) async throws {
guard connection.state == .ready else { throw UITestsSignalError.notConnected }
let data = signal.rawValue.data(using: .utf8)
connection.send(content: data, completion: .idempotent)
}
/// Returns the next message received from the other side of the connection.
func receive() async throws -> UITestsSignal {
guard connection.state == .ready else { throw UITestsSignalError.notConnected }
guard nextMessageContinuation == nil else { throw UITestsSignalError.awaitingAnotherSignal }
return try await withCheckedThrowingContinuation { continuation in
self.nextMessageContinuation = continuation
// Add a 30 second timeout to stop tests from hanging
self.nextMessageTimeoutTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(for: .seconds(30))
guard !Task.isCancelled,
let nextMessageContinuation = self.nextMessageContinuation
else { return }
nextMessageContinuation.resume(throwing: UITestsSignalError.timeout)
self.nextMessageContinuation = nil
self.nextMessageTimeoutTask = nil
}
try fileWatcher.start { [weak self] result in
self?.handleFileRefresh(result)
}
}
/// Processes the next message received by the connection.
fileprivate func receiveNextMessage() {
connection.receiveMessage { [weak self] completeContent, _, isComplete, error in
guard let self else { return }
guard isComplete else { fatalError("Partial messages not supported") }
guard let completeContent,
let message = String(data: completeContent, encoding: .utf8),
let signal = UITestsSignal(rawValue: message)
else {
let error: UITestsSignalError = error.map { .nwError($0) } ?? .unknown
self.nextMessageContinuation?.resume(with: .failure(error))
self.nextMessageContinuation = nil
self.nextMessageTimeoutTask = nil
return
}
if signal != .connect {
self.nextMessageContinuation?.resume(returning: signal)
self.nextMessageContinuation = nil
self.nextMessageTimeoutTask = nil
}
self.receiveNextMessage()
/// Suspends execution until the app's Client has signalled that it's ready.
func waitForApp() async {
guard mode == .tests else { fatalError("The app can't wait for itself.") }
guard !isConnected else { return }
await _ = signals.values.first { $0 == .ready }
NSLog("UITestsSignalling: Connected to app.")
}
/// Stops listening for signals.
func stop() throws {
try fileWatcher.stop()
}
/// Sends a signal.
func send(_ signal: UITestsSignal) throws {
guard isConnected else { throw UITestsSignalError.notConnected }
let rawSignal = rawSignal(signal)
try rawSignal.write(to: fileURL, atomically: false, encoding: .utf8)
NSLog("UITestsSignalling: Sent \(rawSignal)")
}
/// The signal formatted as a string, prefixed with an identifier for the sender.
/// E.g. The tests client would produce `tests:ready` for the ready signal.
private func rawSignal(_ signal: UITestsSignal) -> String {
"\(mode.rawValue):\(signal.rawValue)"
}
/// Handles a file refresh to receive a new signal.
fileprivate func handleFileRefresh(_ result: FileWatcher.RefreshResult) {
switch result {
case .noChanges:
guard let data = try? Data(contentsOf: fileURL) else { return }
processFileData(data)
case .updated(let data):
processFileData(data)
}
}
/// Processes string data from the file and publishes its signal.
private func processFileData(_ data: Data) {
guard let message = String(data: data, encoding: .utf8) else { return }
let components = message.components(separatedBy: ":")
guard components.count == 2,
components[0] != mode.rawValue, // Filter out messages sent by this client.
let signal = UITestsSignal(rawValue: components[1])
else { return }
if signal == .ready {
isConnected = true
}
signals.send(signal)
NSLog("UITestsSignalling: Received \(message)")
}
}
}

View File

@ -120,6 +120,7 @@ targets:
- package: DTCoreText
- package: KeychainAccess
- package: Kingfisher
- package: KZFileWatchers
- package: PostHog
- package: SwiftyBeaver
- package: SwiftState

View File

@ -19,8 +19,6 @@ import XCTest
@MainActor
class RoomScreenUITests: XCTestCase {
let connectionWaitDuration: Duration = .seconds(2)
func testPlainNoAvatar() {
let app = Application.launch(.roomPlainNoAvatar)
@ -46,47 +44,44 @@ class RoomScreenUITests: XCTestCase {
app.assertScreenshot(.roomSmallTimeline)
}
func disabled_testSmallTimelineWithIncomingAndPagination() async throws {
let listener = try UITestsSignalling.Listener()
func testSmallTimelineWithIncomingAndPagination() async throws {
let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomSmallTimelineIncomingAndSmallPagination)
let connection = try await listener.connection()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel...
defer { connection.disconnect() }
await client.waitForApp()
defer { try? client.stop() }
// When a back pagination occurs and an incoming message arrives.
try await performOperation(.incomingMessage, using: connection)
try await performOperation(.paginate, using: connection)
try await performOperation(.incomingMessage, using: client)
try await performOperation(.paginate, using: client)
// Then the 4 visible messages should stay aligned to the bottom.
app.assertScreenshot(.roomSmallTimelineIncomingAndSmallPagination)
}
func disabled_testSmallTimelineWithLargePagination() async throws {
let listener = try UITestsSignalling.Listener()
func testSmallTimelineWithLargePagination() async throws {
let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomSmallTimelineLargePagination)
let connection = try await listener.connection()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel...
defer { connection.disconnect() }
await client.waitForApp()
defer { try? client.stop() }
// When a large back pagination occurs.
try await performOperation(.paginate, using: connection)
try await performOperation(.paginate, using: client)
// The bottom of the timeline should remain visible with more items added above.
app.assertScreenshot(.roomSmallTimelineLargePagination)
}
func disabled_testTimelineLayoutInMiddle() async throws {
let listener = try UITestsSignalling.Listener()
func testTimelineLayoutInMiddle() async throws {
let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomLayoutMiddle)
let connection = try await listener.connection()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel...
defer { connection.disconnect() }
await client.waitForApp()
defer { try? client.stop() }
// Given a timeline that is neither at the top nor the bottom.
app.tables.element.swipeDown()
@ -94,13 +89,13 @@ class RoomScreenUITests: XCTestCase {
app.assertScreenshot(.roomLayoutMiddle, step: 0) // Assert initial state for comparison.
// When a back pagination occurs.
try await performOperation(.paginate, using: connection)
try await performOperation(.paginate, using: client)
// Then the UI should remain unchanged.
app.assertScreenshot(.roomLayoutMiddle, step: 0)
// When an incoming message arrives
try await performOperation(.incomingMessage, using: connection)
try await performOperation(.incomingMessage, using: client)
// Then the UI should still remain unchanged.
app.assertScreenshot(.roomLayoutMiddle, step: 0)
@ -112,14 +107,13 @@ class RoomScreenUITests: XCTestCase {
app.assertScreenshot(.roomLayoutMiddle, step: 1)
}
func disabled_testTimelineLayoutAtTop() async throws {
let listener = try UITestsSignalling.Listener()
func testTimelineLayoutAtTop() async throws {
let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomLayoutTop)
let connection = try await listener.connection()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel...
defer { connection.disconnect() }
await client.waitForApp()
defer { try? client.stop() }
// Given a timeline that is scrolled to the top.
while !app.staticTexts["Bacon ipsum dolor amet commodo incididunt ribeye dolore cupidatat short ribs."].isHittable {
@ -129,23 +123,22 @@ class RoomScreenUITests: XCTestCase {
app.assertScreenshot(.roomLayoutTop, insets: cropped) // Assert initial state for comparison.
// When a back pagination occurs.
try await performOperation(.paginate, using: connection)
try await performOperation(.paginate, using: client)
// Then the bottom of the timeline should remain unchanged (with new items having been added above).
app.assertScreenshot(.roomLayoutTop, insets: cropped)
}
func disabled_testTimelineLayoutAtBottom() async throws {
let listener = try UITestsSignalling.Listener()
func testTimelineLayoutAtBottom() async throws {
let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomLayoutBottom)
let connection = try await listener.connection()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel...
defer { connection.disconnect() }
await client.waitForApp()
defer { try? client.stop() }
// When an incoming message arrives.
try await performOperation(.incomingMessage, using: connection)
try await performOperation(.incomingMessage, using: client)
// Then the timeline should scroll down to reveal the message.
app.assertScreenshot(.roomLayoutBottom, step: 0)
@ -159,10 +152,10 @@ class RoomScreenUITests: XCTestCase {
// MARK: - Helper Methods
private func performOperation(_ operation: UITestsSignal, using connection: UITestsSignalling.Connection) async throws {
try await connection.send(operation)
guard try await connection.receive() == .success else { throw UITestsSignalError.unexpected }
try await Task.sleep(for: connectionWaitDuration) // Allow the timeline to update, and the connection to be ready
private func performOperation(_ operation: UITestsSignal, using client: UITestsSignalling.Client) async throws {
try client.send(operation)
await _ = client.signals.values.first { $0 == .success }
try await Task.sleep(for: .milliseconds(500)) // Allow the timeline to update
}
private func tapMessageComposer(in app: XCUIApplication) async throws {

View File

@ -34,6 +34,7 @@ targets:
- package: DTCoreText
- package: KeychainAccess
- package: Kingfisher
- package: KZFileWatchers
- package: PostHog
- package: SwiftyBeaver
- package: SwiftState

View File

@ -1 +1 @@
Fix UI Tests for OnboardingScreen, BugReportScreen, ServerSelectionScreen, and UserSessionFlows.
Fix UI Tests for OnboardingScreen, BugReportScreen, ServerSelectionScreen, and UserSessionFlows. Fix UITestsSignalling by switching to file-based communication with a publisher.

View File

@ -46,7 +46,7 @@ packages:
path: DesignKit
AnalyticsEvents:
url: https://github.com/matrix-org/matrix-analytics-events
branch: main
exactVersion: 0.4.0
AppAuth:
url: https://github.com/openid/AppAuth-iOS
majorVersion: 1.5.0
@ -65,6 +65,9 @@ packages:
Kingfisher:
url: https://github.com/onevcat/Kingfisher
majorVersion: 7.2.0
KZFileWatchers:
url: https://github.com/krzysztofzablocki/KZFileWatchers
branch: master
Introspect:
url: https://github.com/siteline/SwiftUI-Introspect
majorVersion: 0.1.4