@ -14,12 +14,13 @@
// l i m i t a t i o n s u n d e r t h e L i c e n s e .
//
import Network
import Combine
import KZFileWatchers
import SwiftUI
enum UITestsSignal : String {
// / A n i n t e r n a l s i g n a l u s e d t o br i n g u p t h e c o n n e c t i o n .
case connect
// / A n i n t e r n a l s i g n a l u s e d t o in d i c a t e t h a t o n e s i d e o f t h e c o n n e c t i o n i s r e a d y .
case ready
// / A s k t h e a p p t o b a c k p a g i n a t e .
case paginate
// / A s k t h e a p p t o s i m u l a t e a n i n c o m i n g m e s s a g e .
@ -28,211 +29,127 @@ enum UITestsSignal: String {
case success
}
enum UITestsSignalError : Error {
// / A n u n k n o w n e r r o r o c c u r r e d .
case unknown
// / S i g n a l l i n g c o u l d n o t b e u s e d a s i s h a s n ' t b e e n e n a b l e d .
case disabled
// / T h e c o n n e c t i o n w a s c a n c e l l e d .
case cancelled
// / T h e c o n n e c t i o n h a s n ' t b e e n e s t a b l i s h e d .
enum UITestsSignalError : String , LocalizedError {
// / T h e a p p c l i e n t f a i l e d t o s t a r t a s t h e t e s t s c l i e n t i s n ' t r e a d y .
case testsClientNotReady
// / F a i l e d t o s e n d a s i g n a l a s a c o n n e c t i o n h a s n ' t b e e n e s t a b l i s h e d .
case notConnected
// / A t t e m p t e d t o r e c e i v e m u l t i p l e s i g n a l s a t o n c e .
case awaitingAnotherSignal
// / R e c e i v i n g t h e n e x t s i g n a l t i m e d o u t .
case timeout
// / A n e t w o r k e r r o r o c c u r r e d .
case nwError ( NWError )
// / A n u n e x p e c t e d s i g n a l w a s r e c e i v e d . T h i s e r r o r i s n ' t u s e d i n t e r n a l l y .
case unexpected
var errorDescription : String ? { " UITestsSignalError. \( rawValue ) " }
}
enum UITestsSignalling {
// / T h e B o n j o u r s e r v i c e n a m e u s e d f o r t h e c o n n e c t i o n . T h e d e v i c e n a m e
// / i s i n c l u d e d t o a l l o w U I t e s t s t o r u n o n m u l t i p l e d e v i c e s s i m u l t a n e o u s l y .
private static let serviceName = " UITestsSignalling \( UIDevice . current . name ) ( \( Locale . current . identifier ) ) "
// / T h e B o n j o u r s e r v i c e t y p e u s e d f o r t h e c o n n e c t i o n .
private static let serviceType = " _signalling._udp. "
// / T h e B o n j o u r d o m a i n u s e d f o r t h e c o n n e c t i o n .
private static let domain = " local. "
// / T h e D i s p a t c h Q u e u e u s e d f o r n e t w o r k i n g .
private static let queue : DispatchQueue = . main
// / A n e t w o r k l i s t e n e r t h a t c a n b e u s e d i n t h e U I t e s t s r u n n e r t o c r e a t e a t w o - w a y ` C o n n e c t i o n ` w i t h t h e a p p .
class Listener {
// / T h e u n d e r l y i n g n e t w o r k l i s t e n e r .
private let listener : NWListener
// / A t w o - w a y f i l e - b a s e d s i g n a l l i n g c l i e n t t h a t c a n b e u s e d t o s i g n a l b e t w e e n t h e a p p a n d t h e U I t e s t s r u n n e r .
// / T h e c o n n e c t i o n s h o u l d b e c r e a t e d a s f o l l o w s :
// / - C r e a t e a ` C l i e n t ` i n ` t e s t s ` m o d e i n y o u r U I t e s t s b e f o r e l a u n c h i n g t h e a p p . I t w i l l s t a r t l i s t e n i n g f o r s i g n a l s .
// / - W i t h i n t h e a p p , c r e a t e a ` C l i e n t ` i n ` a p p ` m o d e . T h i s w i l l c h e c k t h a t t h e t e s t s a r e r e a d y a n d e c h o b a c k t h a t t h e a p p i s t o o .
// / - C a l l ` w a i t F o r A p p ( ) ` i n t h e t e s t s w h e n y o u n e e d t o s e n d t h e s i g n a l . T h i s w i l l s u s p e n d e x e c u t i o n u n t i l t h e a p p h a s s i g n a l l e d i t i s r e a d y .
// / - T h e t w o ` C l i e n t ` o b j e c t s c a n n o w b e u s e d f o r t w o - w a y s i g n a l l i n g .
class Client {
// / T h e f i l e w a t c h e r r e s p o n s i b l e f o r r e c e i v i n g s i g n a l s .
private let fileWatcher : FileWatcher . Local
// / T h e e s t a b l i s h e d c o n n e c t i o n . T h i s i s s t o r e d i n c a s e t h e c o n n e c t i o n i s e s t a b l i s h e d
// / b e f o r e ` c o n n e c t i o n ( ) ` i s a w a i t e d a n d s o t h e c o n t i n u a t i o n i s s t i l l ` n i l ` .
private var establishedConnection : Connection ?
// / T h e c o n t i n u a t i o n t o c a l l w h e n a c o n n e c t i o n i s e s t a b l i s h e d .
private var connectionContinuation : CheckedContinuation < Connection , Error > ?
// / T h e f i l e n a m e u s e d f o r t h e c o n n e c t i o n .
// /
// / T h e d e v i c e n a m e i s i n c l u d e d t o a l l o w U I t e s t s t o r u n o n m u l t i p l e d e v i c e s s i m u l t a n e o u s l y .
// / W h e n u s i n g p a r a l l e l e x e c u t i o n , e a c h e x e c u t i o n w i l l s p a w n a s i m u l a t o r c l o n e w i t h i t s o w n u n i q u e n a m e .
private let fileURL = {
let directory = URL ( filePath : " /Users/Shared " )
let deviceName = ( UIDevice . current . name ) . replacing ( " " , with : " - " )
return directory . appending ( component : " UITestsSignalling- \( deviceName ) " )
} ( )
// / C r e a t e s a n e w s i g n a l l i n g ` L i s t e n e r ` a n d s t a r t s l i s t e n i n g .
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 ( ) // S t o p l i s t e n i n g f o r c o n n e c t i o n s w h e n o n e i s d i s c o v e r e d .
}
listener . start ( queue : UITestsSignalling . queue )
}
// / A m o d e t h a t d e f i n e s t h e b e h a v i o u r o f t h e c l i e n t .
enum Mode : String { case app , tests }
// / T h e m o d e t h a t t h e c l i e n t i s u s i n g .
let mode : Mode
// / R e t u r n s t h e n e g o t i a t e d ` C o n n e c t i o n ` a s a n d w h e n i t h a s b e e n e s t a b l i s h e d .
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
}
}
// / A p u b l i s h e r t h e w i l l b e s e n t e v e r y t i m e a n e w s i g n a l i s r e c e i v e d .
let signals = PassthroughSubject < UITestsSignal , Never > ( )
// / S t o p s t h e l i s t e n i n g w h e n a c o n n e c t i o n h a s n ' t b e e n e s t a b l i s h e d .
func cancel ( ) {
listener . cancel ( )
if let connectionContinuation {
connectionContinuation . resume ( throwing : UITestsSignalError . cancelled )
self . connectionContinuation = nil
// / W h e t h e r o r n o t t h e c l i e n t h a s e s t a b l i s h e d a c o n n e c t i o n .
private ( set ) var isConnected = false
// / C r e a t e s a n e w s i g n a l l i n g ` C l i e n t ` .
init ( mode : Mode ) throws {
fileWatcher = . init ( path : fileURL . path ( ) )
self . mode = mode
switch mode {
case . tests :
// T h e t e s t s c l i e n t i s s t a r t e d f i r s t a n d w r i t e s t o t h e f i l e s a y i n g i t i s r e a d y .
try rawSignal ( . ready ) . write ( to : fileURL , atomically : false , encoding : . utf8 )
case . app :
// T h e a p p c l i e n t i s s t a r t e d s e c o n d a n d c h e c k s t h a t t h e r e i s a r e a d y s i g n a l f r o m t h e t e s t s .
guard try String ( contentsOf : fileURL ) = = " \( Mode . tests ) : \( UITestsSignal . ready ) " else { throw UITestsSignalError . testsClientNotReady }
isConnected = true
// T h e a p p c l i e n t t h e n e c h o e s b a c k t o t h e t e s t s t h a t i t i s n o w r e a d y .
try send ( . ready )
}
}
}
// / A t w o - w a y U D P c o n n e c t i o n t h a t c a n b e u s e d f o r s i g n a l l i n g b e t w e e n t h e a p p a n d t h e U I t e s t s r u n n e r .
// / T h e c o n n e c t i o n s h o u l d b e c r e a t e d a s f o l l o w s :
// / - C r e a t e a ` L i s t e n e r ` i n t h e U I t e s t s b e f o r e l a u n c h i n g t h e a p p . T h i s w i l l a u t o m a t i c a l l y s t a r t l i s t e n i n g f o r a c o n n e c t i o n .
// / - W i t h i n t h e A p p , c r e a t e a ` C o n n e c t i o n ` a n d c a l l ` c o n n e c t ( ) ` t o e s t a b l i s h a c o n n e c t i o n .
// / - A w a i t t h e ` c o n n e c t i o n ( ) ` o n t h e ` L i s t e n e r ` w h e n y o u n e e d t o s e n d t h e s i g n a l .
// / - T h e t w o ` C o n n e c t i o n ` o b j e c t s c a n n o w b e u s e d f o r t w o - w a y s i g n a l l i n g .
class Connection {
// / T h e u n d e r l y i n g n e t w o r k c o n n e c t i o n .
private let connection : NWConnection
// / A c o n t i n u a t i o n t o c a l l e a c h t i m e a s i g n a l i s r e c e i v e d .
private var nextMessageContinuation : CheckedContinuation < UITestsSignal , Error > ?
// / A t a s k t o h a n d l e t h e t i m e o u t w h e n r e c e i v i n g a s i g n a l .
private var nextMessageTimeoutTask : Task < Void , Never > ? {
didSet {
oldValue ? . cancel ( )
try fileWatcher . start { [ weak self ] result in
self ? . handleFileRefresh ( result )
}
}
// / Cr e a t e s a n e w s i g n a l l i n g ` C o n n e c t i o n ` .
init( ) {
let endpoint = NWEndpoint . service ( name : UITestsSignalling . serviceName ,
type : UITestsSignalling . serviceType ,
domain : UITestsSignalling . domain ,
interface : nil )
connection = NWConnection ( to : endpoint , using : . udp )
// / S u s p e n d s e x e c u t i o n u n t i l t h e a p p ' s C l i e n t h a s s i g n a l l e d t h a t i t ' s r e a d y .
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. " )
}
// / Cr e a t e s a n e w s i g n a l l i n g ` C o n n e c t i o n ` f r o m a n e s t a b l i s h e d ` N W C o n n e c t i o n ` .
fileprivate init ( nwConnection : NWConnection ) {
connection = nwConnection
// / S t o p s l i s t e n i n g f o r s i g n a l s .
func stop ( ) throws {
try fileWatcher . stop ( )
}
// / At t e m p t s t o e s t a b l i s h a c o n n e c t i o n w i t h a ` L i s t e n e r ` .
func connect( ) async throws {
guard connection. state = = . setup else { return }
// / Se n d s a s i g n a l .
func send( _ signal : UITestsSignal ) throws {
guard isConnected else { throw UITestsSignalError . notConnected }
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
}
}
}
let rawSignal = rawSignal ( signal )
try rawSignal . write ( to : fileURL , atomically : false , encoding : . utf8 )
NSLog ( " UITestsSignalling: Sent \( rawSignal ) " )
}
// / S t o p s t h e c o n n e c t i o n .
func disconnect ( ) {
connection . cancel ( )
if let nextMessageContinuation {
nextMessageContinuation . resume ( throwing : UITestsSignalError . cancelled )
self . nextMessageContinuation = nil
nextMessageTimeoutTask = nil
}
// / T h e s i g n a l f o r m a t t e d a s a s t r i n g , p r e f i x e d w i t h a n i d e n t i f i e r f o r t h e s e n d e r .
// / E . g . T h e t e s t s c l i e n t w o u l d p r o d u c e ` t e s t s : r e a d y ` f o r t h e r e a d y s i g n a l .
private func rawSignal ( _ signal : UITestsSignal ) -> String {
" \( mode . rawValue ) : \( signal . rawValue ) "
}
// / S e n d s a m e s s a g e t o t h e o t h e r s i d e o f t h e c o n n e c t i o n .
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 )
}
// / R e t u r n s t h e n e x t m e s s a g e r e c e i v e d f r o m t h e o t h e r s i d e o f t h e c o n n e c t i o n .
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
// A d d a 3 0 s e c o n d t i m e o u t t o s t o p t e s t s f r o m h a n g i n g
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
}
// / H a n d l e s a f i l e r e f r e s h t o r e c e i v e a n e w s i g n a l .
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 )
}
}
// / P r o c e s s e s t h e n e x t m e s s a g e r e c e i v e d b y t h e c o n n e c t i o n .
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 ( )
// / P r o c e s s e s s t r i n g d a t a f r o m t h e f i l e a n d p u b l i s h e s i t s s i g n a l .
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 , // F i l t e r o u t m e s s a g e s s e n t b y t h i s c l i e n t .
let signal = UITestsSignal ( rawValue : components [ 1 ] )
else { return }
if signal = = . ready {
isConnected = true
}
signals . send ( signal )
NSLog ( " UITestsSignalling: Received \( message ) " )
}
}
}