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
9 changed files with 181 additions and 252 deletions
+4
View File
@@ -40,6 +40,10 @@ function_body_length:
warning: 50 warning: 50
error: 100 error: 100
nesting:
type_level:
warning: 5
custom_rules: custom_rules:
print_deprecation: print_deprecation:
regex: "\\b(print)\\b" regex: "\\b(print)\\b"
@@ -72,13 +72,22 @@
"version" : "7.4.1" "version" : "7.4.1"
} }
}, },
{
"identity" : "kzfilewatchers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/krzysztofzablocki/KZFileWatchers",
"state" : {
"branch" : "master",
"revision" : "d27a9557427d261adccdf4b566acc9d9c0fec6f4"
}
},
{ {
"identity" : "matrix-analytics-events", "identity" : "matrix-analytics-events",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/matrix-org/matrix-analytics-events", "location" : "https://github.com/matrix-org/matrix-analytics-events",
"state" : { "state" : {
"branch" : "main", "revision" : "94598bd8ef3aa2a3d3848c656c37d5e95b6b1cff",
"revision" : "4bcc7f566b165cd2d8fde07d23bda77e1d9fbb2d" "version" : "0.4.0"
} }
}, },
{ {
@@ -32,12 +32,15 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default
private var connection: UITestsSignalling.Connection? private var client: UITestsSignalling.Client?
init(listenForSignals: Bool = false) { init(listenForSignals: Bool = false) {
if listenForSignals { guard listenForSignals else { return }
connection = .init()
Task { try await startListening() } do {
try startListening()
} catch {
fatalError("Failure setting up signalling: \(error)")
} }
} }
@@ -70,20 +73,18 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
// MARK: - UI Test signalling // 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. /// Allows the simulation of server responses by listening for signals from UI tests.
private func startListening() async throws { private func startListening() throws {
try await connection?.connect() let client = try UITestsSignalling.Client(mode: .app)
Task { signalCancellable = client.signals.sink { [weak self] signal in
while let connection { Task { try await self?.handleSignal(signal) }
do {
try await handleSignal(connection.receive())
} catch {
connection.disconnect()
self.connection = nil
}
}
} }
self.client = client
} }
/// Handles a UI test signal as necessary. /// Handles a UI test signal as necessary.
@@ -106,7 +107,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
timelineItems.append(incomingItem) timelineItems.append(incomingItem)
callbacks.send(.updatedTimelineItems) callbacks.send(.updatedTimelineItems)
try? await connection?.send(.success) try client?.send(.success)
} }
/// Prepends the next chunk of items to the `timelineItems` array. /// Prepends the next chunk of items to the `timelineItems` array.
@@ -119,6 +120,6 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
callbacks.send(.updatedTimelineItems) callbacks.send(.updatedTimelineItems)
callbacks.send(.isBackPaginating(false)) callbacks.send(.isBackPaginating(false))
try? await connection?.send(.success) try client?.send(.success)
} }
} }
+110 -193
View File
@@ -14,12 +14,13 @@
// limitations under the License. // limitations under the License.
// //
import Network import Combine
import KZFileWatchers
import SwiftUI import SwiftUI
enum UITestsSignal: String { enum UITestsSignal: String {
/// An internal signal used to bring up the connection. /// An internal signal used to indicate that one side of the connection is ready.
case connect case ready
/// Ask the app to back paginate. /// Ask the app to back paginate.
case paginate case paginate
/// Ask the app to simulate an incoming message. /// Ask the app to simulate an incoming message.
@@ -28,211 +29,127 @@ enum UITestsSignal: String {
case success case success
} }
enum UITestsSignalError: Error { enum UITestsSignalError: String, LocalizedError {
/// An unknown error occurred. /// The app client failed to start as the tests client isn't ready.
case unknown case testsClientNotReady
/// Signalling could not be used as is hasn't been enabled. /// Failed to send a signal as a connection hasn't been established.
case disabled
/// The connection was cancelled.
case cancelled
/// The connection hasn't been established.
case notConnected case notConnected
/// Attempted to receive multiple signals at once.
case awaitingAnotherSignal var errorDescription: String? { "UITestsSignalError.\(rawValue)" }
/// 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
} }
enum UITestsSignalling { enum UITestsSignalling {
/// The Bonjour service name used for the connection. The device name /// A two-way file-based signalling client that can be used to signal between the app and the UI tests runner.
/// 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.
/// The connection should be created as follows: /// 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. /// - Create a `Client` in `tests` mode in your UI tests before launching the app. It will start listening for signals.
/// - With in the App, create a `Connection` and call `connect()` to establish a connection. /// - 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.
/// - Await the `connection()` on the `Listener` when you need to send the signal. /// - 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 `Connection` objects can now be used for two-way signalling. /// - The two `Client` objects can now be used for two-way signalling.
class Connection { class Client {
/// The underlying network connection. /// The file watcher responsible for receiving signals.
private let connection: NWConnection private let fileWatcher: FileWatcher.Local
/// 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()
}
}
/// Creates a new signalling `Connection`. /// The file name used for the connection.
init() { ///
let endpoint = NWEndpoint.service(name: UITestsSignalling.serviceName, /// The device name is included to allow UI tests to run on multiple devices simultaneously.
type: UITestsSignalling.serviceType, /// When using parallel execution, each execution will spawn a simulator clone with its own unique name.
domain: UITestsSignalling.domain, private let fileURL = {
interface: nil) let directory = URL(filePath: "/Users/Shared")
connection = NWConnection(to: endpoint, using: .udp) let deviceName = (UIDevice.current.name).replacing(" ", with: "-")
} return directory.appending(component: "UITestsSignalling-\(deviceName)")
}()
/// Creates a new signalling `Connection` from an established `NWConnection`. /// A mode that defines the behaviour of the client.
fileprivate init(nwConnection: NWConnection) { enum Mode: String { case app, tests }
connection = nwConnection /// The mode that the client is using.
} let mode: Mode
/// Attempts to establish a connection with a `Listener`. /// A publisher the will be sent every time a new signal is received.
func connect() async throws { let signals = PassthroughSubject<UITestsSignal, Never>()
guard connection.state == .setup else { return }
/// 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 switch mode {
connection.start(queue: UITestsSignalling.queue) case .tests:
connection.stateUpdateHandler = { state in // The tests client is started first and writes to the file saying it is ready.
switch state { try rawSignal(.ready).write(to: fileURL, atomically: false, encoding: .utf8)
case .ready: case .app:
self.receiveNextMessage() // The app client is started second and checks that there is a ready signal from the tests.
continuation.resume() guard try String(contentsOf: fileURL) == "\(Mode.tests):\(UITestsSignal.ready)" else { throw UITestsSignalError.testsClientNotReady }
Task { try await self.send(.connect) } isConnected = true
case .failed(let error): // The app client then echoes back to the tests that it is now ready.
continuation.resume(with: .failure(error)) try send(.ready)
default:
break
}
}
} }
}
/// 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 try fileWatcher.start { [weak self] result in
self.nextMessageContinuation = continuation self?.handleFileRefresh(result)
// 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
}
} }
} }
/// Processes the next message received by the connection. /// Suspends execution until the app's Client has signalled that it's ready.
fileprivate func receiveNextMessage() { func waitForApp() async {
connection.receiveMessage { [weak self] completeContent, _, isComplete, error in guard mode == .tests else { fatalError("The app can't wait for itself.") }
guard let self else { return }
guard isComplete else { fatalError("Partial messages not supported") } guard !isConnected else { return }
await _ = signals.values.first { $0 == .ready }
guard let completeContent, NSLog("UITestsSignalling: Connected to app.")
let message = String(data: completeContent, encoding: .utf8), }
let signal = UITestsSignal(rawValue: message)
else { /// Stops listening for signals.
let error: UITestsSignalError = error.map { .nwError($0) } ?? .unknown func stop() throws {
self.nextMessageContinuation?.resume(with: .failure(error)) try fileWatcher.stop()
self.nextMessageContinuation = nil }
self.nextMessageTimeoutTask = nil
return /// Sends a signal.
} func send(_ signal: UITestsSignal) throws {
guard isConnected else { throw UITestsSignalError.notConnected }
if signal != .connect {
self.nextMessageContinuation?.resume(returning: signal) let rawSignal = rawSignal(signal)
self.nextMessageContinuation = nil try rawSignal.write(to: fileURL, atomically: false, encoding: .utf8)
self.nextMessageTimeoutTask = nil NSLog("UITestsSignalling: Sent \(rawSignal)")
} }
self.receiveNextMessage() /// 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)")
}
} }
} }
+1
View File
@@ -120,6 +120,7 @@ targets:
- package: DTCoreText - package: DTCoreText
- package: KeychainAccess - package: KeychainAccess
- package: Kingfisher - package: Kingfisher
- package: KZFileWatchers
- package: PostHog - package: PostHog
- package: SwiftyBeaver - package: SwiftyBeaver
- package: SwiftState - package: SwiftState
+31 -38
View File
@@ -19,8 +19,6 @@ import XCTest
@MainActor @MainActor
class RoomScreenUITests: XCTestCase { class RoomScreenUITests: XCTestCase {
let connectionWaitDuration: Duration = .seconds(2)
func testPlainNoAvatar() { func testPlainNoAvatar() {
let app = Application.launch(.roomPlainNoAvatar) let app = Application.launch(.roomPlainNoAvatar)
@@ -46,47 +44,44 @@ class RoomScreenUITests: XCTestCase {
app.assertScreenshot(.roomSmallTimeline) app.assertScreenshot(.roomSmallTimeline)
} }
func disabled_testSmallTimelineWithIncomingAndPagination() async throws { func testSmallTimelineWithIncomingAndPagination() async throws {
let listener = try UITestsSignalling.Listener() let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomSmallTimelineIncomingAndSmallPagination) let app = Application.launch(.roomSmallTimelineIncomingAndSmallPagination)
let connection = try await listener.connection() await client.waitForApp()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... defer { try? client.stop() }
defer { connection.disconnect() }
// When a back pagination occurs and an incoming message arrives. // When a back pagination occurs and an incoming message arrives.
try await performOperation(.incomingMessage, using: connection) try await performOperation(.incomingMessage, using: client)
try await performOperation(.paginate, using: connection) try await performOperation(.paginate, using: client)
// Then the 4 visible messages should stay aligned to the bottom. // Then the 4 visible messages should stay aligned to the bottom.
app.assertScreenshot(.roomSmallTimelineIncomingAndSmallPagination) app.assertScreenshot(.roomSmallTimelineIncomingAndSmallPagination)
} }
func disabled_testSmallTimelineWithLargePagination() async throws { func testSmallTimelineWithLargePagination() async throws {
let listener = try UITestsSignalling.Listener() let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomSmallTimelineLargePagination) let app = Application.launch(.roomSmallTimelineLargePagination)
let connection = try await listener.connection() await client.waitForApp()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... defer { try? client.stop() }
defer { connection.disconnect() }
// When a large back pagination occurs. // 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. // The bottom of the timeline should remain visible with more items added above.
app.assertScreenshot(.roomSmallTimelineLargePagination) app.assertScreenshot(.roomSmallTimelineLargePagination)
} }
func disabled_testTimelineLayoutInMiddle() async throws { func testTimelineLayoutInMiddle() async throws {
let listener = try UITestsSignalling.Listener() let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomLayoutMiddle) let app = Application.launch(.roomLayoutMiddle)
let connection = try await listener.connection() await client.waitForApp()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... defer { try? client.stop() }
defer { connection.disconnect() }
// Given a timeline that is neither at the top nor the bottom. // Given a timeline that is neither at the top nor the bottom.
app.tables.element.swipeDown() app.tables.element.swipeDown()
@@ -94,13 +89,13 @@ class RoomScreenUITests: XCTestCase {
app.assertScreenshot(.roomLayoutMiddle, step: 0) // Assert initial state for comparison. app.assertScreenshot(.roomLayoutMiddle, step: 0) // Assert initial state for comparison.
// When a back pagination occurs. // When a back pagination occurs.
try await performOperation(.paginate, using: connection) try await performOperation(.paginate, using: client)
// Then the UI should remain unchanged. // Then the UI should remain unchanged.
app.assertScreenshot(.roomLayoutMiddle, step: 0) app.assertScreenshot(.roomLayoutMiddle, step: 0)
// When an incoming message arrives // When an incoming message arrives
try await performOperation(.incomingMessage, using: connection) try await performOperation(.incomingMessage, using: client)
// Then the UI should still remain unchanged. // Then the UI should still remain unchanged.
app.assertScreenshot(.roomLayoutMiddle, step: 0) app.assertScreenshot(.roomLayoutMiddle, step: 0)
@@ -112,14 +107,13 @@ class RoomScreenUITests: XCTestCase {
app.assertScreenshot(.roomLayoutMiddle, step: 1) app.assertScreenshot(.roomLayoutMiddle, step: 1)
} }
func disabled_testTimelineLayoutAtTop() async throws { func testTimelineLayoutAtTop() async throws {
let listener = try UITestsSignalling.Listener() let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomLayoutTop) let app = Application.launch(.roomLayoutTop)
let connection = try await listener.connection() await client.waitForApp()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... defer { try? client.stop() }
defer { connection.disconnect() }
// Given a timeline that is scrolled to the top. // Given a timeline that is scrolled to the top.
while !app.staticTexts["Bacon ipsum dolor amet commodo incididunt ribeye dolore cupidatat short ribs."].isHittable { 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. app.assertScreenshot(.roomLayoutTop, insets: cropped) // Assert initial state for comparison.
// When a back pagination occurs. // 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). // Then the bottom of the timeline should remain unchanged (with new items having been added above).
app.assertScreenshot(.roomLayoutTop, insets: cropped) app.assertScreenshot(.roomLayoutTop, insets: cropped)
} }
func disabled_testTimelineLayoutAtBottom() async throws { func testTimelineLayoutAtBottom() async throws {
let listener = try UITestsSignalling.Listener() let client = try UITestsSignalling.Client(mode: .tests)
let app = Application.launch(.roomLayoutBottom) let app = Application.launch(.roomLayoutBottom)
let connection = try await listener.connection() await client.waitForApp()
try await Task.sleep(for: connectionWaitDuration) // Allow the connection to settle on CI/Intel... defer { try? client.stop() }
defer { connection.disconnect() }
// When an incoming message arrives. // 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. // Then the timeline should scroll down to reveal the message.
app.assertScreenshot(.roomLayoutBottom, step: 0) app.assertScreenshot(.roomLayoutBottom, step: 0)
@@ -159,10 +152,10 @@ class RoomScreenUITests: XCTestCase {
// MARK: - Helper Methods // MARK: - Helper Methods
private func performOperation(_ operation: UITestsSignal, using connection: UITestsSignalling.Connection) async throws { private func performOperation(_ operation: UITestsSignal, using client: UITestsSignalling.Client) async throws {
try await connection.send(operation) try client.send(operation)
guard try await connection.receive() == .success else { throw UITestsSignalError.unexpected } await _ = client.signals.values.first { $0 == .success }
try await Task.sleep(for: connectionWaitDuration) // Allow the timeline to update, and the connection to be ready try await Task.sleep(for: .milliseconds(500)) // Allow the timeline to update
} }
private func tapMessageComposer(in app: XCUIApplication) async throws { private func tapMessageComposer(in app: XCUIApplication) async throws {
+1
View File
@@ -34,6 +34,7 @@ targets:
- package: DTCoreText - package: DTCoreText
- package: KeychainAccess - package: KeychainAccess
- package: Kingfisher - package: Kingfisher
- package: KZFileWatchers
- package: PostHog - package: PostHog
- package: SwiftyBeaver - package: SwiftyBeaver
- package: SwiftState - package: SwiftState
+1 -1
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.
+4 -1
View File
@@ -46,7 +46,7 @@ packages:
path: DesignKit path: DesignKit
AnalyticsEvents: AnalyticsEvents:
url: https://github.com/matrix-org/matrix-analytics-events url: https://github.com/matrix-org/matrix-analytics-events
branch: main exactVersion: 0.4.0
AppAuth: AppAuth:
url: https://github.com/openid/AppAuth-iOS url: https://github.com/openid/AppAuth-iOS
majorVersion: 1.5.0 majorVersion: 1.5.0
@@ -65,6 +65,9 @@ packages:
Kingfisher: Kingfisher:
url: https://github.com/onevcat/Kingfisher url: https://github.com/onevcat/Kingfisher
majorVersion: 7.2.0 majorVersion: 7.2.0
KZFileWatchers:
url: https://github.com/krzysztofzablocki/KZFileWatchers
branch: master
Introspect: Introspect:
url: https://github.com/siteline/SwiftUI-Introspect url: https://github.com/siteline/SwiftUI-Introspect
majorVersion: 0.1.4 majorVersion: 0.1.4