packages feed

marionette 1.0.0 → 1.1.0

raw patch · 12 files changed

+1051/−337 lines, 12 filesdep +directorydep +filepathdep +temporarydep ~containersPVP ok

version bump matches the API change (PVP)

Dependencies added: directory, filepath, temporary

Dependency ranges changed: containers

API changes (from Hackage documentation)

- Test.Marionette.Client: CommandWithCallback :: Command -> TMVar Result -> CommandWithCallback
- Test.Marionette.Client: UnexpectedResult :: Result -> UnexpectedResult
- Test.Marionette.Client: data CommandWithCallback
- Test.Marionette.Client: instance GHC.Internal.Base.Monad m => Control.Monad.Reader.Class.MonadReader (Control.Concurrent.STM.TQueue.TQueue Test.Marionette.Client.CommandWithCallback) (Test.Marionette.Client.MarionetteT m)
- Test.Marionette.Client: instance GHC.Internal.Exception.Type.Exception Test.Marionette.Client.UnexpectedResult
- Test.Marionette.Client: instance GHC.Internal.Show.Show Test.Marionette.Client.UnexpectedResult
- Test.Marionette.Client: newtype UnexpectedResult
+ Test.Marionette: defaultCommandTimeout :: Int
+ Test.Marionette: runMarionetteTWith :: (MonadUnliftIO m, MonadMask m) => HostName -> PortNumber -> Int -> MarionetteT m a -> m a
+ Test.Marionette.Actions: BackButton :: Button
+ Test.Marionette.Actions: ForwardButton :: Button
+ Test.Marionette.Actions: FromElement :: Element -> Origin
+ Test.Marionette.Actions: KeyDown :: Key -> KeyAction
+ Test.Marionette.Actions: KeyInput :: Text -> [KeyAction] -> InputSource
+ Test.Marionette.Actions: KeyPause :: Int -> KeyAction
+ Test.Marionette.Actions: KeyUp :: Key -> KeyAction
+ Test.Marionette.Actions: LeftButton :: Button
+ Test.Marionette.Actions: MiddleButton :: Button
+ Test.Marionette.Actions: Mouse :: PointerType
+ Test.Marionette.Actions: NoneInput :: Text -> [NoneAction] -> InputSource
+ Test.Marionette.Actions: NonePause :: Int -> NoneAction
+ Test.Marionette.Actions: Pen :: PointerType
+ Test.Marionette.Actions: Pointer :: Origin
+ Test.Marionette.Actions: PointerDown :: Button -> PointerAction
+ Test.Marionette.Actions: PointerInput :: Text -> PointerType -> [PointerAction] -> InputSource
+ Test.Marionette.Actions: PointerMove :: Int -> Origin -> Int -> Int -> PointerAction
+ Test.Marionette.Actions: PointerPause :: Int -> PointerAction
+ Test.Marionette.Actions: PointerUp :: Button -> PointerAction
+ Test.Marionette.Actions: RightButton :: Button
+ Test.Marionette.Actions: Scroll :: Int -> Origin -> Int -> Int -> Int -> Int -> WheelAction
+ Test.Marionette.Actions: Touch :: PointerType
+ Test.Marionette.Actions: Viewport :: Origin
+ Test.Marionette.Actions: WheelInput :: Text -> [WheelAction] -> InputSource
+ Test.Marionette.Actions: WheelPause :: Int -> WheelAction
+ Test.Marionette.Actions: [deltaX] :: WheelAction -> Int
+ Test.Marionette.Actions: [deltaY] :: WheelAction -> Int
+ Test.Marionette.Actions: [durationMs] :: NoneAction -> Int
+ Test.Marionette.Actions: [keyActions] :: InputSource -> [KeyAction]
+ Test.Marionette.Actions: [noneActions] :: InputSource -> [NoneAction]
+ Test.Marionette.Actions: [origin] :: WheelAction -> Origin
+ Test.Marionette.Actions: [pointerActions] :: InputSource -> [PointerAction]
+ Test.Marionette.Actions: [pointerType] :: InputSource -> PointerType
+ Test.Marionette.Actions: [sourceId] :: InputSource -> Text
+ Test.Marionette.Actions: [wheelActions] :: InputSource -> [WheelAction]
+ Test.Marionette.Actions: [x] :: WheelAction -> Int
+ Test.Marionette.Actions: [y] :: WheelAction -> Int
+ Test.Marionette.Actions: data Button
+ Test.Marionette.Actions: data InputSource
+ Test.Marionette.Actions: data KeyAction
+ Test.Marionette.Actions: data Origin
+ Test.Marionette.Actions: data PointerAction
+ Test.Marionette.Actions: data PointerType
+ Test.Marionette.Actions: data WheelAction
+ Test.Marionette.Actions: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Actions.InputSource
+ Test.Marionette.Actions: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Actions.KeyAction
+ Test.Marionette.Actions: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Actions.NoneAction
+ Test.Marionette.Actions: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Actions.Origin
+ Test.Marionette.Actions: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Actions.PointerAction
+ Test.Marionette.Actions: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Actions.PointerType
+ Test.Marionette.Actions: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Actions.WheelAction
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.Button
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.InputSource
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.KeyAction
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.NoneAction
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.Origin
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.PointerAction
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.PointerType
+ Test.Marionette.Actions: instance GHC.Classes.Eq Test.Marionette.Actions.WheelAction
+ Test.Marionette.Actions: instance GHC.Internal.Enum.Bounded Test.Marionette.Actions.Button
+ Test.Marionette.Actions: instance GHC.Internal.Enum.Enum Test.Marionette.Actions.Button
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.Button
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.InputSource
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.KeyAction
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.NoneAction
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.Origin
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.PointerAction
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.PointerType
+ Test.Marionette.Actions: instance GHC.Internal.Show.Show Test.Marionette.Actions.WheelAction
+ Test.Marionette.Actions: keyboard :: [KeyAction] -> InputSource
+ Test.Marionette.Actions: mouse :: [PointerAction] -> InputSource
+ Test.Marionette.Actions: newtype NoneAction
+ Test.Marionette.Actions: pen :: [PointerAction] -> InputSource
+ Test.Marionette.Actions: touch :: [PointerAction] -> InputSource
+ Test.Marionette.Actions: wheel :: [WheelAction] -> InputSource
+ Test.Marionette.Client: ClientEnv :: TQueue QueuedCommand -> TMVar SomeException -> Int -> TVar Int -> TVar (IntMap (TMVar Result)) -> ClientEnv
+ Test.Marionette.Client: QueuedCommand :: Int -> Command -> QueuedCommand
+ Test.Marionette.Client: [commandTimeout] :: ClientEnv -> Int
+ Test.Marionette.Client: [connectionLost] :: ClientEnv -> TMVar SomeException
+ Test.Marionette.Client: [nextMessageId] :: ClientEnv -> TVar Int
+ Test.Marionette.Client: [pendingCommands] :: ClientEnv -> TVar (IntMap (TMVar Result))
+ Test.Marionette.Client: [sendQueue] :: ClientEnv -> TQueue QueuedCommand
+ Test.Marionette.Client: data ClientEnv
+ Test.Marionette.Client: data QueuedCommand
+ Test.Marionette.Client: defaultCommandTimeout :: Int
+ Test.Marionette.Client: instance GHC.Internal.Base.Monad m => Control.Monad.Reader.Class.MonadReader Test.Marionette.Client.ClientEnv (Test.Marionette.Client.MarionetteT m)
+ Test.Marionette.Client: runMarionetteTWith :: (MonadUnliftIO m, MonadMask m) => HostName -> PortNumber -> Int -> MarionetteT m a -> m a
+ Test.Marionette.Key: Add :: Key
+ Test.Marionette.Key: Alt :: Key
+ Test.Marionette.Key: Backspace :: Key
+ Test.Marionette.Key: Cancel :: Key
+ Test.Marionette.Key: Clear :: Key
+ Test.Marionette.Key: Control :: Key
+ Test.Marionette.Key: Decimal :: Key
+ Test.Marionette.Key: Delete :: Key
+ Test.Marionette.Key: Divide :: Key
+ Test.Marionette.Key: DownArrow :: Key
+ Test.Marionette.Key: End :: Key
+ Test.Marionette.Key: Enter :: Key
+ Test.Marionette.Key: Equals :: Key
+ Test.Marionette.Key: Escape :: Key
+ Test.Marionette.Key: F1 :: Key
+ Test.Marionette.Key: F10 :: Key
+ Test.Marionette.Key: F11 :: Key
+ Test.Marionette.Key: F12 :: Key
+ Test.Marionette.Key: F2 :: Key
+ Test.Marionette.Key: F3 :: Key
+ Test.Marionette.Key: F4 :: Key
+ Test.Marionette.Key: F5 :: Key
+ Test.Marionette.Key: F6 :: Key
+ Test.Marionette.Key: F7 :: Key
+ Test.Marionette.Key: F8 :: Key
+ Test.Marionette.Key: F9 :: Key
+ Test.Marionette.Key: Help :: Key
+ Test.Marionette.Key: Home :: Key
+ Test.Marionette.Key: Insert :: Key
+ Test.Marionette.Key: Key :: Char -> Key
+ Test.Marionette.Key: Left :: Key
+ Test.Marionette.Key: Meta :: Key
+ Test.Marionette.Key: Multiply :: Key
+ Test.Marionette.Key: Null :: Key
+ Test.Marionette.Key: Numpad0 :: Key
+ Test.Marionette.Key: Numpad1 :: Key
+ Test.Marionette.Key: Numpad2 :: Key
+ Test.Marionette.Key: Numpad3 :: Key
+ Test.Marionette.Key: Numpad4 :: Key
+ Test.Marionette.Key: Numpad5 :: Key
+ Test.Marionette.Key: Numpad6 :: Key
+ Test.Marionette.Key: Numpad7 :: Key
+ Test.Marionette.Key: Numpad8 :: Key
+ Test.Marionette.Key: Numpad9 :: Key
+ Test.Marionette.Key: PageDown :: Key
+ Test.Marionette.Key: PageUp :: Key
+ Test.Marionette.Key: Pause :: Key
+ Test.Marionette.Key: Return :: Key
+ Test.Marionette.Key: Right :: Key
+ Test.Marionette.Key: Semicolon :: Key
+ Test.Marionette.Key: Separator :: Key
+ Test.Marionette.Key: Shift :: Key
+ Test.Marionette.Key: Space :: Key
+ Test.Marionette.Key: Subtract :: Key
+ Test.Marionette.Key: Tab :: Key
+ Test.Marionette.Key: UpArrow :: Key
+ Test.Marionette.Key: ZenkakuHankaku :: Key
+ Test.Marionette.Key: char :: Key -> Char
+ Test.Marionette.Key: data Key
+ Test.Marionette.Key: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Key.Key
+ Test.Marionette.Key: instance GHC.Classes.Eq Test.Marionette.Key.Key
+ Test.Marionette.Key: instance GHC.Internal.Show.Show Test.Marionette.Key.Key
+ Test.Marionette.Protocol: instance Data.Aeson.Types.ToJSON.ToJSON Test.Marionette.Protocol.Greeting
+ Test.Marionette.Selector: instance GHC.Classes.Ord Test.Marionette.Selector.Selector
- Test.Marionette.Client: MarionetteT :: ReaderT (TQueue CommandWithCallback) m a -> MarionetteT (m :: Type -> Type) a
+ Test.Marionette.Client: MarionetteT :: ReaderT ClientEnv m a -> MarionetteT (m :: Type -> Type) a
- Test.Marionette.Client: MarionetteTimeout :: MarionetteMessage -> MarionetteTimeout
+ Test.Marionette.Client: MarionetteTimeout :: Command -> MarionetteTimeout
- Test.Marionette.Client: incoming :: (MonadUnliftIO m, MonadThrow m, Binary a) => Socket -> m (TQueue a)
+ Test.Marionette.Client: incoming :: (MonadUnliftIO m, MonadCatch m, Binary a) => Socket -> (SomeException -> m ()) -> m (TQueue a)
- Test.Marionette.Commands: performActions :: (HasCallStack, Marionette m) => m ()
+ Test.Marionette.Commands: performActions :: (HasCallStack, Marionette m) => [InputSource] -> m ()

Files

CHANGELOG.md view
@@ -5,6 +5,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to the [Haskell Package Versioning Policy](https://pvp.haskell.org/). +## [1.1.0] - 2026-10-01++### Added++- `runMarionetteTWith`, allowing connections to an arbitrary host and port.+- `ClientEnv`, the reader environment that `MarionetteT` runs against.+- `Test.Marionette.Actions` and `Test.Marionette.Key` modules to support structured virtual input device actions.++### Removed++- `UnexpectedResult`, unexpected / timed out responses are now discarded rather than ending the session.+- `CommandWithCallback`, replaced by `QueuedCommand`.++### Changed++- `incoming` takes a callback to run if reading or decoding the connection ever fails,+  instead of asynchronously killing the whole session.+- `MarionetteTimeout` carries the `Command` that timed out instead of the raw wire message.++### Fixed++- Command timeouts no longer close the session.+- Raised command timeout from 5s to 15s.+- Closed connection no longer spin the reader.+- Command callback are before sending instead of after.+- Timed-out commands are deregistered.+- `Test.Marionette.Commands.performActions` now accepts action sequences to simulate keyboard, mouse, and touch events, rather than doing nothing.++## [1.0.1] - 2026-08-07++### Fixed++- Rethrow async exceptions in the Client.+ ## [1.0.0] - 2026-07-14  ### Added
marionette.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: marionette-version: 1.0.0+version: 1.1.0 category: Web synopsis: Marionette protocol for Firefox description:@@ -77,7 +77,7 @@     base64-bytestring >=1.2 && <1.3,     binary >=0.8 && <0.9,     binary-parsers >=0.2 && <0.3,-    containers >=0.7 && <0.8,+    containers >=0.7 && <0.9,     exceptions >=0.10 && <0.11,     hashable >=1.5 && <1.6,     mtl >=2.3 && <2.4,@@ -89,6 +89,7 @@   exposed-modules:     Test.Marionette     Test.Marionette.AccessibilityProperties+    Test.Marionette.Actions     Test.Marionette.Class     Test.Marionette.Client     Test.Marionette.Commands@@ -96,6 +97,7 @@     Test.Marionette.Cookie     Test.Marionette.Element     Test.Marionette.Frame+    Test.Marionette.Key     Test.Marionette.Orientation     Test.Marionette.Protocol     Test.Marionette.Rect@@ -112,11 +114,35 @@   hs-source-dirs: test   main-is: Main.hs   build-depends:+    directory >=1.3 && <1.4,+    filepath >=1.5 && <1.6,     hspec >=2.11 && <2.12,     hspec-expectations-lifted >=0.10 && <0.11,     http-types >=0.12 && <0.13,     lucid2 >=0.0 && <0.1,     marionette,+    network >=3.2 && <3.3,+    retry >=0.9 && <0.10,+    temporary >=1.3 && <1.4,     typed-process >=0.2 && <0.3,     wai >=3.2 && <3.3,     warp >=3.4 && <3.5,++test-suite marionette-client-test+  import: common+  type: exitcode-stdio-1.0+  ghc-options:+    -threaded+    -with-rtsopts=-N+    -Wno-unused-packages++  hs-source-dirs: test+  main-is: ClientSpec.hs+  build-depends:+    binary >=0.8 && <0.9,+    hspec >=2.11 && <2.12,+    hspec-expectations-lifted >=0.10 && <0.11,+    marionette,+    network >=3.2 && <3.3,+    network-simple >=0.4.5 && <0.5,+    unliftio >=0.2 && <0.3,
src/Test/Marionette.hs view
@@ -66,7 +66,8 @@ -- >   (elementClick =<< findElement (ById "primary-button")) -- >     `catchError` \_ -> elementClick =<< findElement (ById "fallback-button") module Test.Marionette-    ( module Test.Marionette.Class+    ( module Test.Marionette.Actions+    , module Test.Marionette.Class     , module Test.Marionette.Client     , module Test.Marionette.Commands     , module Test.Marionette.Cookie@@ -84,8 +85,14 @@     ) where +import Test.Marionette.Actions import Test.Marionette.Class (Marionette)-import Test.Marionette.Client (MarionetteT, runMarionetteT)+import Test.Marionette.Client+    ( MarionetteT+    , defaultCommandTimeout+    , runMarionetteT+    , runMarionetteTWith+    ) import Test.Marionette.Commands import Test.Marionette.Context (Context (..)) import Test.Marionette.Cookie (Cookie (Cookie))
+ src/Test/Marionette/Actions.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE DuplicateRecordFields #-}+{-# OPTIONS_GHC -Wno-partial-fields #-}++-- | The <W3C https://w3c.github.io/webdriver/#actions Actions> API.+module Test.Marionette.Actions where++import Data.Aeson (ToJSON (toJSON), Value (String), object, (.=))+import Data.Text (Text)+import Data.Text qualified as Text+import Test.Marionette.Element (Element)+import Test.Marionette.Key (Key)+import Prelude++-- | Where a pointer or wheel action is anchored.+data Origin+    = -- | The top-left of the viewport.+      Viewport+    | -- | The input source's current position.+      Pointer+    | -- | The centre of the given element.+      FromElement Element+    deriving stock (Show, Eq)++instance ToJSON Origin where+    toJSON Viewport = String "viewport"+    toJSON Pointer = String "pointer"+    toJSON (FromElement element) = toJSON element++-- | Which mouse button an action addresses.+data Button+    = LeftButton+    | MiddleButton+    | RightButton+    | BackButton+    | ForwardButton+    deriving stock (Show, Eq, Enum, Bounded)++-- | The kind of physical device a pointer input source represents.+data PointerType = Mouse | Pen | Touch+    deriving stock (Show, Eq)++instance ToJSON PointerType where+    toJSON = String . Text.toLower . Text.show++-- | One tick of a pointer input source's action list.+data PointerAction+    = -- | Do nothing for this many milliseconds, or until every other+      -- source's action in this tick has finished, whichever is longer.+      PointerPause {durationMs :: Int}+    | -- | Move to (or by, relative to the 'Origin') the given position over+      -- the given number of milliseconds.+      PointerMove {durationMs :: Int, origin :: Origin, x :: Int, y :: Int}+    | PointerDown Button+    | PointerUp Button+    deriving stock (Show, Eq)++instance ToJSON PointerAction where+    toJSON PointerPause{..} =+        object+            [ "type" .= String "pause"+            , "duration" .= durationMs+            ]+    toJSON PointerMove{..} =+        object+            [ "type" .= String "pointerMove"+            , "duration" .= durationMs+            , "origin" .= origin+            , "x" .= x+            , "y" .= y+            ]+    toJSON (PointerDown button) =+        object+            [ "type" .= String "pointerDown"+            , "button" .= fromEnum button+            ]+    toJSON (PointerUp button) =+        object+            [ "type" .= String "pointerUp"+            , "button" .= fromEnum button+            ]++-- | One tick of a key input source's action list.+data KeyAction+    = KeyPause {durationMs :: Int}+    | KeyDown Key+    | KeyUp Key+    deriving stock (Show, Eq)++instance ToJSON KeyAction where+    toJSON KeyPause{..} =+        object+            [ "type" .= String "pause"+            , "duration" .= durationMs+            ]+    toJSON (KeyDown key) =+        object+            [ "type" .= String "keyDown"+            , "value" .= key+            ]+    toJSON (KeyUp key) =+        object+            [ "type" .= String "keyUp"+            , "value" .= key+            ]++-- | One tick of a wheel input source's action list.+data WheelAction+    = WheelPause {durationMs :: Int}+    | -- | Scroll by the given @(deltaX, deltaY)@ pixels, anchored at the+      -- given @(x, y)@ relative to the 'Origin', over the given number of+      -- milliseconds.+      Scroll+        { durationMs :: Int+        , origin :: Origin+        , x :: Int+        , y :: Int+        , deltaX :: Int+        , deltaY :: Int+        }+    deriving stock (Show, Eq)++instance ToJSON WheelAction where+    toJSON WheelPause{..} =+        object+            [ "type" .= String "pause"+            , "duration" .= durationMs+            ]+    toJSON Scroll{..} =+        object+            [ "type" .= String "scroll"+            , "duration" .= durationMs+            , "origin" .= origin+            , "x" .= x+            , "y" .= y+            , "deltaX" .= deltaX+            , "deltaY" .= deltaY+            ]++-- | One tick of a "none" input source's action list.+newtype NoneAction = NonePause {durationMs :: Int}+    deriving stock (Show, Eq)++instance ToJSON NoneAction where+    toJSON NonePause{..} =+        object+            [ "type" .= String "pause"+            , "duration" .= durationMs+            ]++-- | A virtual input device and the sequence of actions to dispatch on it.+data InputSource+    = PointerInput+        { sourceId :: Text+        , pointerType :: PointerType+        , pointerActions :: [PointerAction]+        }+    | KeyInput+        { sourceId :: Text+        , keyActions :: [KeyAction]+        }+    | WheelInput+        { sourceId :: Text+        , wheelActions :: [WheelAction]+        }+    | NoneInput+        { sourceId :: Text+        , noneActions :: [NoneAction]+        }+    deriving stock (Show, Eq)++instance ToJSON InputSource where+    toJSON PointerInput{..} =+        object+            [ "type" .= String "pointer"+            , "id" .= sourceId+            , "parameters" .= object ["pointerType" .= pointerType]+            , "actions" .= pointerActions+            ]+    toJSON KeyInput{..} =+        object+            [ "type" .= String "key"+            , "id" .= sourceId+            , "actions" .= keyActions+            ]+    toJSON WheelInput{..} =+        object+            [ "type" .= String "wheel"+            , "id" .= sourceId+            , "actions" .= wheelActions+            ]+    toJSON NoneInput{..} =+        object+            [ "type" .= String "none"+            , "id" .= sourceId+            , "actions" .= noneActions+            ]++-- | A single mouse pointer input source named @"mouse"@.+mouse :: [PointerAction] -> InputSource+mouse = PointerInput "mouse" Mouse++-- | A single pen pointer input source named @"pen"@.+pen :: [PointerAction] -> InputSource+pen = PointerInput "pen" Pen++-- | A single touch pointer input source named @"touch"@.+touch :: [PointerAction] -> InputSource+touch = PointerInput "touch" Touch++-- | A single keyboard input source named @"keyboard"@.+keyboard :: [KeyAction] -> InputSource+keyboard = KeyInput "keyboard"++-- | A single wheel input source named @"wheel"@.+wheel :: [WheelAction] -> InputSource+wheel = WheelInput "wheel"
src/Test/Marionette/Client.hs view
@@ -4,8 +4,9 @@  module Test.Marionette.Client where -import Control.Exception (AssertionFailed (AssertionFailed))-import Control.Monad (void, when)+import Control.Applicative ((<|>))+import Control.Exception (AssertionFailed (AssertionFailed), SomeException)+import Control.Monad (void, (<=<)) import Control.Monad.Catch     ( Exception (..)     , MonadCatch@@ -13,6 +14,7 @@     , MonadThrow     , catch     , catchAll+    , finally     , throwM     ) import Control.Monad.Error.Class (MonadError (..))@@ -27,9 +29,10 @@ import Data.Binary qualified as Binary import Data.Binary.Get qualified as Binary import Data.ByteString (ByteString)+import Data.ByteString qualified as BS import Data.ByteString.Builder.Extra qualified as ByteString+import Data.IntMap (IntMap) import Data.IntMap.Strict qualified as IntMap-import Data.Maybe (isNothing) import GHC.Stack (HasCallStack) import Network.Simple.TCP     ( HostName@@ -40,6 +43,7 @@     , connectSock     , sendLazy     )+import Network.Socket (PortNumber) import Network.Socket.ByteString (recv) import System.Timeout (timeout) import Test.Marionette.Class (Marionette (..))@@ -54,10 +58,10 @@     , link     , readTMVar     )-import UnliftIO.Concurrent (forkIO) import UnliftIO.Retry (constantDelay, limitRetriesByCumulativeDelay, recoverAll) import UnliftIO.STM-    ( atomically+    ( TVar+    , atomically     , modifyTVar'     , newEmptyTMVarIO     , newTQueueIO@@ -65,6 +69,7 @@     , putTMVar     , readTQueue     , stateTVar+    , tryPutTMVar     , writeTQueue     ) import Prelude hiding (log)@@ -79,12 +84,13 @@  incoming     :: forall m a-     . (MonadUnliftIO m, MonadThrow m, Binary a)+     . (MonadUnliftIO m, MonadCatch m, Binary a)     => Socket+    -> (SomeException -> m ())     -> m (TQueue a)-incoming socket = do+incoming socket onFailure = do     q <- newTQueueIO-    link =<< async (go (atomically . writeTQueue q) (newDecoder ""))+    void . async $ go (atomically . writeTQueue q) (newDecoder "") `catch` onFailure     pure q   where     newDecoder :: ByteString -> Binary.Decoder a@@ -93,10 +99,12 @@     go f (Binary.Done rest _ a) = do         f a         go f $ newDecoder rest-    go f dec =-        go f . (dec `Binary.pushChunk`)-            =<< liftIO-                (recv socket ByteString.defaultChunkSize `catchAll` \_ -> throwM SocketClosed)+    go f dec = do+        chunk <-+            liftIO (recv socket ByteString.defaultChunkSize `catchAll` \_ -> throwM SocketClosed)+        if BS.null chunk+            then throwM SocketClosed+            else go f (dec `Binary.pushChunk` chunk)  connect     :: (MonadUnliftIO m)@@ -112,19 +120,26 @@         )         (closeSock . fst) -newtype MarionetteTimeout = MarionetteTimeout MarionetteMessage+newtype MarionetteTimeout = MarionetteTimeout Command     deriving stock (Show)     deriving anyclass (Exception) -newtype UnexpectedResult = UnexpectedResult Result-    deriving stock (Show)-    deriving anyclass (Exception)+data QueuedCommand = QueuedCommand Int Command -data CommandWithCallback = CommandWithCallback Command (TMVar Result)+data ClientEnv = ClientEnv+    { sendQueue :: TQueue QueuedCommand+    , connectionLost :: TMVar SomeException+    , commandTimeout :: Int+    , nextMessageId :: TVar Int+    , pendingCommands :: TVar (IntMap (TMVar Result))+    } --- | A monad transformer that speaks the Marionette wire protocol over a TCP--- socket. Run it with 'runMarionetteT'.-newtype MarionetteT m a = MarionetteT (ReaderT (TQueue CommandWithCallback) m a)+defaultCommandTimeout :: Int+defaultCommandTimeout = 15_000_000++-- | A monad transformer that speaks the Marionette wire protocol over a TCP socket.+-- Run it with 'runMarionetteT'.+newtype MarionetteT m a = MarionetteT (ReaderT ClientEnv m a)     deriving newtype         ( Functor         , Applicative@@ -134,16 +149,29 @@         , MonadMask         , MonadIO         , MonadUnliftIO-        , MonadReader (TQueue CommandWithCallback)+        , MonadReader ClientEnv         )  instance (MonadUnliftIO m, MonadThrow m, MonadCatch m) => Marionette (MarionetteT m) where     sendCommand :: (HasCallStack, FromJSON a) => Command -> MarionetteT m a     sendCommand command = do-        q <- Reader.ask+        ClientEnv{..} <- Reader.ask         result <- newEmptyTMVarIO-        atomically . writeTQueue q $ CommandWithCallback command result-        either throwM pure =<< parseResult =<< atomically (readTMVar result)+        messageId <- atomically do+            messageId <- stateTVar nextMessageId $ \n -> (n, n + 1)+            modifyTVar' pendingCommands $ IntMap.insert messageId result+            writeTQueue sendQueue $ QueuedCommand messageId command+            pure messageId+        outcome <-+            liftIO . timeout commandTimeout . atomically $+                (Right <$> readTMVar result) <|> (Left <$> readTMVar connectionLost)+        case outcome of+            Nothing -> atomically . modifyTVar' pendingCommands $ IntMap.delete messageId+            Just _ -> pure ()+        maybe+            (throwM . MarionetteTimeout $ command)+            (either throwM (either throwM pure <=< parseResult))+            outcome       where         parseResult :: (FromJSON a) => Result -> MarionetteT m (Either Error a)         parseResult =@@ -155,16 +183,20 @@     throwError = throwM     catchError = catch --- | Connect to a Marionette server listening on @localhost:2828@ (started--- by launching Firefox with the @--marionette@ flag), and run the given--- action against it.-runMarionetteT+-- | Run an action against a Marionette server.+runMarionetteTWith     :: forall m a      . (MonadUnliftIO m, MonadMask m)-    => MarionetteT m a+    => HostName+    -> PortNumber+    -> Int+    -- ^ Command timeout, in microseconds.+    -> MarionetteT m a     -> m a-runMarionetteT action = do-    sendQueue :: TQueue CommandWithCallback <- newTQueueIO+runMarionetteTWith host port commandTimeout action = do+    sendQueue :: TQueue QueuedCommand <- newTQueueIO+    connectionLost <- newEmptyTMVarIO+    nextMessageId <- newTVarIO 1     pendingCommands <- newTVarIO mempty     done <- newEmptyTMVarIO     let repeatUntilDone :: forall m'. (MonadIO m') => m' () -> m' ()@@ -180,44 +212,45 @@                         IntMap.updateLookupWithKey (\_ _ -> Nothing) messageId                     )                     >>= \case-                        Nothing -> throwM . UnexpectedResult $ messageContent+                        Nothing -> pure () -- a late or unsolicited reply; not fatal                         Just result -> atomically $ putTMVar result messageContent-        handleCommand :: Socket -> Int -> CommandWithCallback -> MarionetteT m ()-        handleCommand socket messageId (CommandWithCallback messageContent callback) = do-            let message = MarionetteMessage . Aeson.encode $ Message{..}-            liftIO . sendLazy socket . Binary.encode $ message-            forkIO do-                result <- liftIO . timeout 5_000_000 . atomically . readTMVar $ callback-                when (isNothing result) . throwM . MarionetteTimeout $ message-            atomically . modifyTVar' pendingCommands $-                IntMap.insert messageId callback+        failPending :: SomeException -> MarionetteT m ()+        failPending = void . atomically . tryPutTMVar connectionLost+        handleCommand :: Socket -> QueuedCommand -> MarionetteT m ()+        handleCommand socket (QueuedCommand messageId messageContent) =+            liftIO . sendLazy socket . Binary.encode . MarionetteMessage . Aeson.encode $+                Message{..}         runSocket :: MarionetteT m ()         runSocket =-            connect "localhost" "2828" \(socket, _) -> do-                incomingQueue <- incoming socket+            connect host (show port) \(socket, _) -> do+                incomingQueue <- incoming socket failPending                 void . decodeMarionetteM @_ @Greeting =<< atomically (readTQueue incomingQueue)-                let send messageId =+                let send =                         atomically                             ( isEmptyTMVar done >>= \case                                 True -> Just <$> readTQueue sendQueue                                 False -> pure Nothing                             )-                            >>= \case-                                Nothing -> pure ()-                                Just command -> do-                                    handleCommand socket messageId command-                                    send $ messageId + 1-                forkIO $ send 1-                forkIO . repeatUntilDone $ handleIncoming =<< atomically (readTQueue incomingQueue)+                            >>= maybe (pure ()) ((>> send) . handleCommand socket)+                linkedAsync send+                void . async $+                    repeatUntilDone (handleIncoming =<< atomically (readTQueue incomingQueue))+                        `catch` failPending                 atomically $ readTMVar done-    flip runReaderT sendQueue . (\(MarionetteT r) -> r) $ do-        forkIO runSocket-        a <- action-        atomically $ putTMVar done ()-        pure a+    flip runReaderT ClientEnv{..} . (\(MarionetteT r) -> r) $ do+        linkedAsync runSocket+        action `finally` atomically (void $ tryPutTMVar done ())   where+    linkedAsync :: MarionetteT m () -> MarionetteT m ()+    linkedAsync = link <=< async+     decodeMarionetteM :: forall m' a'. (MonadThrow m', FromJSON a') => MarionetteMessage -> m' a'     decodeMarionetteM = either (throwM . AssertionFailed) pure . decodeMarionette      decodeMarionette :: forall a'. (FromJSON a') => MarionetteMessage -> Either String a'     decodeMarionette (MarionetteMessage lbs) = Aeson.eitherDecode lbs++-- | Run an action against a Marionette server listening on @localhost:2828@+-- (started by launching Firefox with the @--marionette@ flag).+runMarionetteT :: (MonadUnliftIO m, MonadMask m) => MarionetteT m a -> m a+runMarionetteT = runMarionetteTWith "localhost" 2828 defaultCommandTimeout
src/Test/Marionette/Commands.hs view
@@ -19,6 +19,7 @@ import GHC.Generics (Generic) import GHC.Stack (HasCallStack) import Test.Marionette.AccessibilityProperties (AccessibilityProperties)+import Test.Marionette.Actions (InputSource) import Test.Marionette.Class import Test.Marionette.Context (Context) import Test.Marionette.Cookie (Cookie)@@ -53,8 +54,7 @@  type role ValueObject representational --- | Enable or disable accepting new socket connections.--- This has no effect on existing connections.+-- | Enable or disable accepting new socket connections. Has no effect on existing connections. acceptConnections :: (HasCallStack, Marionette m) => Bool -> m () acceptConnections value =     sendCommand_@@ -63,8 +63,8 @@             , parameters = Aeson.object ["value" .= value]             } --- | Look up accessibility properties of a node in the platform accessibility tree,--- rather than by a web element reference.+-- | Look up accessibility properties of a node in the platform accessibility tree, rather than+-- by a web element reference. getAccessibilityPropertiesForAccessibilityNode     :: (HasCallStack, Marionette m)     => Text@@ -77,8 +77,7 @@             , parameters = Aeson.object ["nodeId" .= nodeId]             } --- | Look up the accessibility properties exposed to assistive technology--- (such as screen readers) for a given element.+-- | Look up the accessibility properties exposed to assistive technology for a given element. getAccessibilityPropertiesForElement     :: (HasCallStack, Marionette m)     => Element@@ -90,11 +89,9 @@             , parameters = Aeson.object ["id" .= elementId]             } --- | Return whether subsequent browsing-context-scoped commands (such as--- 'navigate' or 'findElement') target privileged browser chrome or ordinary--- web content.--- New sessions start in the 'Content' context; use 'setContext' to switch,--- for example when driving browser UI rather than a web page.+-- | Return whether subsequent browsing-context-scoped commands target privileged browser chrome+-- or ordinary web content.+-- New sessions start in the 'Content' context. getContext :: (HasCallStack, Marionette m) => m Context getContext = value <$> sendCommand "Marionette:GetContext" @@ -102,21 +99,20 @@ getScreenOrientation :: (HasCallStack, Marionette m) => m Orientation getScreenOrientation = value <$> sendCommand "Marionette:GetScreenOrientation" --- | Return the chrome window's @windowtype@ attribute, for example--- @navigator:browser@ for a normal browser window.+-- | Return the chrome window's @windowtype@ attribute, for example @navigator:browser@ for a+-- normal browser window. getWindowType :: (HasCallStack, Marionette m) => m Text getWindowType = value <$> sendCommand "Marionette:GetWindowType" --- | Shut the browser down: Marionette first stops accepting new--- connections, then ends the current session, and finally causes the--- application itself to quit.+-- | Shut the browser down: Marionette first stops accepting new connections, then ends the+-- current session, and finally causes the application itself to quit. quit :: (HasCallStack, Marionette m) => m () quit = sendCommand_ "Marionette:Quit" --- | Register a chrome protocol handler for a directory containing XHTML or--- XUL files, allowing them to be loaded via the @chrome://@ protocol.--- Return the identifier for the registered handler which can be unregistered--- with 'unregisterChromeHandler'.+-- | Register a chrome protocol handler for a directory containing XHTML or XUL files, allowing+-- them to be loaded via the @chrome://@ protocol.+-- Return the identifier for the registered handler which can be unregistered with+-- 'unregisterChromeHandler'. registerChromeHandler :: (HasCallStack, Marionette m) => Text -> [RegistryEntry] -> m Text registerChromeHandler manifestPath entries =     sendCommand@@ -129,9 +125,8 @@                     ]             } --- | Set the context of the subsequent commands. All subsequent requests to--- commands that in some way involve interaction with a browsing context--- will target the chosen context.+-- | Set the context of the subsequent commands. All subsequent requests to commands that in some+-- way involve interaction with a browsing context will target the chosen context. setContext :: (HasCallStack, Marionette m) => Context -> m () setContext value =     sendCommand_@@ -149,8 +144,8 @@             , parameters = Aeson.object ["orientation" .= orientation]             } --- | Unregister a previously registered chrome protocol handler, given an--- identifier returned by 'registerChromeHandler'.+-- | Unregister a previously registered chrome protocol handler, given an identifier returned by+-- 'registerChromeHandler'. unregisterChromeHandler :: (HasCallStack, Marionette m) => Text -> m () unregisterChromeHandler handlerId =     sendCommand_@@ -159,17 +154,15 @@             , parameters = Aeson.object ["id" .= handlerId]             } --- | Accept a currently displayed dialog modal, or throw if no modal is--- displayed.+-- | Accept a currently displayed dialog modal, or throw if no modal is displayed. ----- See <https://w3c.github.io/webdriver/#accept-alert>+-- See <https://w3c.github.io/webdriver/#accept-alert>. acceptAlert :: (HasCallStack, Marionette m) => m () acceptAlert = sendCommand_ "WebDriver:AcceptAlert" --- | Add a single cookie to the cookie store associated with the active--- document's address.+-- | Add a single cookie to the cookie store associated with the active document's address. ----- See <https://w3c.github.io/webdriver/#add-cookie>+-- See <https://w3c.github.io/webdriver/#add-cookie>. addCookie :: (HasCallStack, Marionette m) => Cookie -> m () addCookie cookie =     sendCommand_@@ -178,36 +171,33 @@             , parameters = Aeson.object ["cookie" .= cookie]             } --- | Cause the browser to traverse one step backward in the joint history--- of the current browsing context.+-- | Traverse one step backward in the history of the current browsing context. ----- See <https://w3c.github.io/webdriver/#back>+-- See <https://w3c.github.io/webdriver/#back>. back :: (HasCallStack, Marionette m) => m () back = sendCommand_ "WebDriver:Back" --- | Close the currently selected chrome window. If it is the last window--- currently open, the chrome window is not closed, to prevent an--- application shutdown.+-- | Close the currently selected chrome window.+-- Does not close the last window currently open to prevent an application shutdown. closeChromeWindow :: (HasCallStack, Marionette m) => m () closeChromeWindow = sendCommand_ "WebDriver:CloseChromeWindow" --- | Close the currently selected tab or window. With multiple open tabs,--- only the selected tab is closed; if it is the last window currently--- open, the window is not closed, to prevent an application shutdown.+-- | Close the currently selected tab or window.+-- Does not close the last window currently open to prevent an application shutdown. ----- See <https://w3c.github.io/webdriver/#close-window>+-- See <https://w3c.github.io/webdriver/#close-window>. closeWindow :: (HasCallStack, Marionette m) => m () closeWindow = sendCommand_ "WebDriver:CloseWindow"  -- | Delete all cookies that are visible to a document. ----- See <https://w3c.github.io/webdriver/#delete-all-cookies>+-- See <https://w3c.github.io/webdriver/#delete-all-cookies>. deleteAllCookies :: (HasCallStack, Marionette m) => m () deleteAllCookies = sendCommand_ "WebDriver:DeleteAllCookies"  -- | Delete a cookie by name. ----- See <https://w3c.github.io/webdriver/#delete-cookie>+-- See <https://w3c.github.io/webdriver/#delete-cookie>. deleteCookie :: (HasCallStack, Marionette m) => Text -> m () deleteCookie name =     sendCommand_@@ -218,20 +208,20 @@  -- | Delete the current WebDriver session. ----- See <https://w3c.github.io/webdriver/#delete-session>+-- See <https://w3c.github.io/webdriver/#delete-session>. deleteSession :: (HasCallStack, Marionette m) => m () deleteSession = sendCommand_ "WebDriver:DeleteSession"  -- | Dismiss a currently displayed modal dialog, or throw if no modal is -- displayed. ----- See <https://w3c.github.io/webdriver/#dismiss-alert>+-- See <https://w3c.github.io/webdriver/#dismiss-alert>. dismissAlert :: (HasCallStack, Marionette m) => m () dismissAlert = sendCommand_ "WebDriver:DismissAlert"  -- | Clear the text of an element. ----- See <https://w3c.github.io/webdriver/#element-clear>+-- See <https://w3c.github.io/webdriver/#element-clear>. elementClear :: (HasCallStack, Marionette m) => Element -> m () elementClear Element{..} =     sendCommand_@@ -242,7 +232,7 @@  -- | Send a click event to an element. ----- See <https://w3c.github.io/webdriver/#element-click>+-- See <https://w3c.github.io/webdriver/#element-click>. elementClick :: (HasCallStack, Marionette m) => Element -> m () elementClick Element{..} =     sendCommand_@@ -253,7 +243,7 @@  -- | Send key presses to an element after focusing on it. ----- See <https://w3c.github.io/webdriver/#element-send-keys>+-- See <https://w3c.github.io/webdriver/#element-send-keys>. elementSendKeys :: (HasCallStack, Marionette m) => Element -> Text -> m () elementSendKeys Element{..} text =     sendCommand_@@ -266,11 +256,10 @@                     ]             } --- | Execute a JavaScript function asynchronously in the context of the--- current browsing context, and return the value passed to the callback,--- which is always the last argument exposed to the script.+-- | Execute a JavaScript function asynchronously in the context of the current browsing context,+-- and return the value passed to the callback, which is the last argument exposed to the script. ----- See <https://w3c.github.io/webdriver/#execute-async-script>+-- See <https://w3c.github.io/webdriver/#execute-async-script>. executeAsyncScript     :: (HasCallStack, Marionette m, Foldable f, FromJSON a)     => Text@@ -287,10 +276,10 @@                     ]             } --- | Execute a JavaScript function synchronously in the context of the--- current browsing context, and return its return value.+-- | Execute a JavaScript function synchronously in the context of the current browsing context,+-- and return its return value. ----- See <https://w3c.github.io/webdriver/#execute-script>+-- See <https://w3c.github.io/webdriver/#execute-script>. executeScript :: (HasCallStack, Marionette m, Foldable f, FromJSON a) => Text -> f Value -> m a executeScript script args =     fmap value . sendCommand $@@ -305,7 +294,7 @@  -- | Find an element using the given search strategy. ----- See <https://w3c.github.io/webdriver/#find-element>+-- See <https://w3c.github.io/webdriver/#find-element>. findElement :: (HasCallStack, Marionette m) => Selector -> m Element findElement selector =     value@@ -315,10 +304,9 @@                 , parameters = toJSON selector                 } --- | Find an element using the given search strategy, relative to the--- given element.+-- | Find an element using the given search strategy, relative to the given element. ----- See <https://w3c.github.io/webdriver/#find-element>+-- See <https://w3c.github.io/webdriver/#find-element>. findElementFrom :: (HasCallStack, Marionette m) => Element -> Selector -> m Element findElementFrom element selector =     value@@ -330,7 +318,7 @@  -- | Find an element within a shadow root using the given search strategy. ----- See <https://w3c.github.io/webdriver/#find-element-from-shadow-root>+-- See <https://w3c.github.io/webdriver/#find-element-from-shadow-root>. findElementFromShadowRoot :: (HasCallStack, Marionette m) => Shadow -> Selector -> m Element findElementFromShadowRoot shadowRoot selector =     value@@ -342,7 +330,7 @@  -- | Find elements using the given search strategy. ----- See <https://w3c.github.io/webdriver/#find-elements>+-- See <https://w3c.github.io/webdriver/#find-elements>. findElements :: (HasCallStack, Marionette m) => Selector -> m [Element] findElements selector =     sendCommand@@ -351,10 +339,9 @@             , parameters = toJSON selector             } --- | Find elements using the given search strategy, relative to the given--- element.+-- | Find elements using the given search strategy, relative to the given element. ----- See <https://w3c.github.io/webdriver/#find-elements>+-- See <https://w3c.github.io/webdriver/#find-elements>. findElementsFrom :: (HasCallStack, Marionette m) => Element -> Selector -> m [Element] findElementsFrom element selector =     sendCommand@@ -365,7 +352,7 @@  -- | Find elements within a shadow root using the given search strategy. ----- See <https://w3c.github.io/webdriver/#find-elements-from-shadow-root>+-- See <https://w3c.github.io/webdriver/#find-elements-from-shadow-root>. findElementsFromShadowRoot :: (HasCallStack, Marionette m) => Shadow -> Selector -> m [Element] findElementsFromShadowRoot shadowRoot selector =     sendCommand@@ -374,36 +361,33 @@             , parameters = toJSON $ SelectorFromShadowRoot shadowRoot selector             } --- | Cause the browser to traverse one step forward in the joint history--- of the current browsing context.+-- | Traverse one step forward in the history of the current browsing context. ----- See <https://w3c.github.io/webdriver/#forward>+-- See <https://w3c.github.io/webdriver/#forward>. forward :: (HasCallStack, Marionette m) => m () forward = sendCommand_ "WebDriver:Forward" --- | Set the window to full screen, as if the user had chosen--- View -> Enter Full Screen. Not supported on Android.+-- | Set the window to full screen. Not supported on Android. ----- See <https://w3c.github.io/webdriver/#fullscreen-window>+-- See <https://w3c.github.io/webdriver/#fullscreen-window>. fullscreenWindow :: (HasCallStack, Marionette m) => m () fullscreenWindow = sendCommand_ "WebDriver:FullscreenWindow"  -- | Return the active element in the document. ----- See <https://w3c.github.io/webdriver/#get-active-element>+-- See <https://w3c.github.io/webdriver/#get-active-element>. getActiveElement :: (HasCallStack, Marionette m) => m Element getActiveElement = value <$> sendCommand "WebDriver:GetActiveElement" --- | Return the message shown in a currently displayed modal, or throw if--- no modal is displayed.+-- | Return the message shown in a currently displayed modal, or throw if no modal is displayed. ----- See <https://w3c.github.io/webdriver/#get-alert-text>+-- See <https://w3c.github.io/webdriver/#get-alert-text>. getAlertText :: (HasCallStack, Marionette m) => m Text getAlertText = value <$> sendCommand "WebDriver:GetAlertText"  -- | Determine the accessibility label for an element. ----- See <https://w3c.github.io/webdriver/#get-computed-label>+-- See <https://w3c.github.io/webdriver/#get-computed-label>. getComputedLabel :: (HasCallStack, Marionette m) => Element -> m Text getComputedLabel Element{..} =     value@@ -415,7 +399,7 @@  -- | Determine the accessibility role for an element. ----- See <https://w3c.github.io/webdriver/#get-computed-role>+-- See <https://w3c.github.io/webdriver/#get-computed-role>. getComputedRole :: (HasCallStack, Marionette m) => Element -> m Text getComputedRole Element{..} =     value@@ -425,24 +409,23 @@                 , parameters = Aeson.object ["id" .= elementId]                 } --- | Get all the cookies for the current domain. Equivalent to calling--- @document.cookie@ and parsing the result.+-- | Get all the cookies for the current domain. Equivalent to calling @document.cookie@ and+-- parsing the result. ----- See <https://w3c.github.io/webdriver/#get-all-cookies>+-- See <https://w3c.github.io/webdriver/#get-all-cookies>. getCookies :: (HasCallStack, Marionette m) => m [Cookie] getCookies = sendCommand "WebDriver:GetCookies" --- | Get a string representing the current URL, equivalent to--- @document.location.href@. When in the chrome context, return the--- canonical URL of the current resource.+-- | Get a string representing the current URL, equivalent to @document.location.href@.+-- When in the chrome context, return the canonical URL of the current resource. ----- See <https://w3c.github.io/webdriver/#get-current-url>+-- See <https://w3c.github.io/webdriver/#get-current-url>. getCurrentURL :: (HasCallStack, Marionette m) => m Text getCurrentURL = value <$> sendCommand "WebDriver:GetCurrentURL"  -- | Get the value of the given attribute of an element. ----- See <https://w3c.github.io/webdriver/#get-element-attribute>+-- See <https://w3c.github.io/webdriver/#get-element-attribute>. getElementAttribute :: (HasCallStack, Marionette m) => Text -> Element -> m (Maybe Text) getElementAttribute attr Element{..} =     value@@ -458,7 +441,7 @@  -- | Get the given CSS property of the computed style of an element. ----- See <https://w3c.github.io/webdriver/#get-element-css-value>+-- See <https://w3c.github.io/webdriver/#get-element-css-value>. getElementCSSValue :: (HasCallStack, Marionette m) => Element -> Text -> m (Maybe Text) getElementCSSValue Element{..} propertyName =     value@@ -474,7 +457,7 @@  -- | Get the value of the given property of an element. ----- See <https://w3c.github.io/webdriver/#get-element-property>+-- See <https://w3c.github.io/webdriver/#get-element-property>. getElementProperty :: (HasCallStack, Marionette m) => Element -> Text -> m (Maybe Text) getElementProperty Element{..} name =     value@@ -490,7 +473,7 @@  -- | Get the dimensions and coordinates of an element. ----- See <https://w3c.github.io/webdriver/#get-element-rect>+-- See <https://w3c.github.io/webdriver/#get-element-rect>. getElementRect :: (HasCallStack, Marionette m) => Element -> m Rect getElementRect Element{..} =     sendCommand@@ -501,7 +484,7 @@  -- | Get the local tag name of an element. ----- See <https://w3c.github.io/webdriver/#get-element-tag-name>+-- See <https://w3c.github.io/webdriver/#get-element-tag-name>. getElementTagName :: (HasCallStack, Marionette m) => Element -> m Text getElementTagName Element{..} =     value@@ -511,10 +494,9 @@                 , parameters = Aeson.object ["id" .= elementId]                 } --- | Get the rendered text of an element, including the text of all its--- child elements.+-- | Get the rendered text of an element, including the text of all its child elements. ----- See <https://w3c.github.io/webdriver/#get-element-text>+-- See <https://w3c.github.io/webdriver/#get-element-text>. getElementText :: (HasCallStack, Marionette m) => Element -> m Text getElementText Element{..} =     value@@ -526,13 +508,13 @@  -- | Get the page source of the content document, serialised as a string. ----- See <https://w3c.github.io/webdriver/#get-page-source>+-- See <https://w3c.github.io/webdriver/#get-page-source>. getPageSource :: (HasCallStack, Marionette m) => m Text getPageSource = value <$> sendCommand "WebDriver:GetPageSource"  -- | Get the shadow root of an element. ----- See <https://w3c.github.io/webdriver/#get-element-shadow-root>+-- See <https://w3c.github.io/webdriver/#get-element-shadow-root>. getShadowRoot :: (HasCallStack, Marionette m) => Element -> m Shadow getShadowRoot Element{..} =     value@@ -544,42 +526,40 @@  -- | Get the timeouts for page loading, searching, and scripts. ----- See <https://w3c.github.io/webdriver/#get-timeouts>+-- See <https://w3c.github.io/webdriver/#get-timeouts>. getTimeouts :: (HasCallStack, Marionette m) => m Timeouts getTimeouts = sendCommand "WebDriver:GetTimeouts"  -- | Get the current title of the window. ----- See <https://w3c.github.io/webdriver/#get-title>+-- See <https://w3c.github.io/webdriver/#get-title>. getTitle :: (HasCallStack, Marionette m) => m Text getTitle = value <$> sendCommand "WebDriver:GetTitle" --- | Get the current window's handle: an opaque, server-assigned--- identifier that can be used to switch back to this window later with--- 'switchToWindow'.+-- | Get the current window's handle: an opaque, server-assigned identifier+-- that can be used to switch back to this window later with 'switchToWindow'. ----- See <https://w3c.github.io/webdriver/#get-window-handle>+-- See <https://w3c.github.io/webdriver/#get-window-handle>. getWindowHandle :: (HasCallStack, Marionette m) => m WindowHandle getWindowHandle = value <$> sendCommand "WebDriver:GetWindowHandle" --- | Get the unordered list of window handles of every open top-level--- browsing context.+-- | Get the unordered list of window handles of every open top-level browsing context. ----- See <https://w3c.github.io/webdriver/#get-window-handles>+-- See <https://w3c.github.io/webdriver/#get-window-handles>. getWindowHandles :: (HasCallStack, Marionette m) => m [WindowHandle] getWindowHandles = sendCommand "WebDriver:GetWindowHandles"  -- | Get the position and size of the browser window currently in focus.--- The width and height refer to the window's @outerWidth@ and--- @outerHeight@, which include scroll bars, title bars, and so on.+-- The width and height refer to the window's @outerWidth@ and @outerHeight@,+-- which include scroll bars, title bars, and so on. ----- See <https://w3c.github.io/webdriver/#get-window-rect>+-- See <https://w3c.github.io/webdriver/#get-window-rect>. getWindowRect :: (HasCallStack, Marionette m) => m Rect getWindowRect = sendCommand "WebDriver:GetWindowRect"  -- | Check whether an element is displayed. ----- See <https://w3c.github.io/webdriver/#element-displayedness>+-- See <https://w3c.github.io/webdriver/#element-displayedness>. isElementDisplayed :: (HasCallStack, Marionette m) => Element -> m Bool isElementDisplayed Element{..} =     value@@ -591,7 +571,7 @@  -- | Check whether an element is enabled. ----- See <https://w3c.github.io/webdriver/#is-element-enabled>+-- See <https://w3c.github.io/webdriver/#is-element-enabled>. isElementEnabled :: (HasCallStack, Marionette m) => Element -> m Bool isElementEnabled Element{..} =     value@@ -603,7 +583,7 @@  -- | Check whether an element is selected. ----- See <https://w3c.github.io/webdriver/#is-element-selected>+-- See <https://w3c.github.io/webdriver/#is-element-selected>. isElementSelected :: (HasCallStack, Marionette m) => Element -> m Bool isElementSelected Element{..} =     value@@ -616,21 +596,21 @@ -- | Maximise the window, as if the user had pressed the maximise button. -- Not supported on Android. ----- See <https://w3c.github.io/webdriver/#maximize-window>+-- See <https://w3c.github.io/webdriver/#maximize-window>. maximizeWindow :: (HasCallStack, Marionette m) => m () maximizeWindow = sendCommand_ "WebDriver:MaximizeWindow"  -- | Minimise the window, as if the user had pressed the minimise button. -- Not supported on Android. ----- See <https://w3c.github.io/webdriver/#minimize-window>+-- See <https://w3c.github.io/webdriver/#minimize-window>. minimizeWindow :: (HasCallStack, Marionette m) => m () minimizeWindow = sendCommand_ "WebDriver:MinimizeWindow" --- | Navigate to the given URL, waiting for the document to load or the--- session's page load timeout to elapse before returning.+-- | Navigate to the given URL, waiting for the document to load or the session's page load+-- timeout to elapse before returning. ----- See <https://w3c.github.io/webdriver/#navigate-to>+-- See <https://w3c.github.io/webdriver/#navigate-to>. navigate :: (HasCallStack, Marionette m) => Text -> m () navigate url =     sendCommand_@@ -639,16 +619,15 @@             , parameters = Aeson.object ["url" .= url]             } --- | Create a new WebDriver session. Must be called before performing any--- other command.+-- | Create a new WebDriver session. Must be called before performing any other command. ----- See <https://w3c.github.io/webdriver/#new-session>+-- See <https://w3c.github.io/webdriver/#new-session>. newSession :: (HasCallStack, Marionette m) => m () newSession = sendCommand_ "WebDriver:NewSession"  -- | Open a new top-level browsing context of type window. ----- See <https://w3c.github.io/webdriver/#new-window>+-- See <https://w3c.github.io/webdriver/#new-window>. newWindow :: (HasCallStack, Marionette m) => m NewWindowResult newWindow =     sendCommand@@ -659,7 +638,7 @@  -- | Open a new top-level browsing context of type tab. ----- See <https://w3c.github.io/webdriver/#new-window>+-- See <https://w3c.github.io/webdriver/#new-window>. newTab :: (HasCallStack, Marionette m) => m NewWindowResult newTab =     sendCommand@@ -668,37 +647,39 @@             , parameters = Aeson.object ["type" .= Tab]             } --- | Perform a series of grouped input actions at the specified points in--- time.+-- | Perform a series of grouped input actions at the specified points in time. ----- See <https://w3c.github.io/webdriver/#perform-actions>-performActions :: (HasCallStack, Marionette m) => m ()-performActions = sendCommand_ "WebDriver:PerformActions"+-- See <https://w3c.github.io/webdriver/#perform-actions>.+performActions :: (HasCallStack, Marionette m) => [InputSource] -> m ()+performActions actions =+    sendCommand_+        Command+            { command = "WebDriver:PerformActions"+            , parameters = Aeson.object ["actions" .= actions]+            }  -- | Print the current page, returning it as a base64-encoded PDF. ----- See <https://w3c.github.io/webdriver/#print-page>+-- See <https://w3c.github.io/webdriver/#print-page>. print :: (HasCallStack, Marionette m) => m () print = sendCommand_ "WebDriver:Print"  -- | Reload the page in the current top-level browsing context. ----- See <https://w3c.github.io/webdriver/#refresh>+-- See <https://w3c.github.io/webdriver/#refresh>. refresh :: (HasCallStack, Marionette m) => m () refresh = sendCommand_ "WebDriver:Refresh" --- | Release all the keys and pointer buttons that are currently--- depressed.+-- | Release all the keys and pointer buttons that are currently depressed. ----- See <https://w3c.github.io/webdriver/#release-actions>+-- See <https://w3c.github.io/webdriver/#release-actions>. releaseActions :: (HasCallStack, Marionette m) => m () releaseActions = sendCommand_ "WebDriver:ReleaseActions" --- | Send keys to the input field of a currently displayed modal, or--- throw if no modal is displayed, or the modal has no means for text--- input.+-- | Send keys to the input field of a currently displayed modal, or throw if no modal is+-- displayed, or the modal has no means for text input. ----- See <https://w3c.github.io/webdriver/#send-alert-text>+-- See <https://w3c.github.io/webdriver/#send-alert-text>. sendAlertText :: (HasCallStack, Marionette m) => Text -> m () sendAlertText text =     sendCommand_@@ -707,16 +688,15 @@             , parameters = Aeson.object ["text" .= text]             } --- | Simulate user modification of a permission descriptor's permission--- state.+-- | Simulate user modification of a permission descriptor's permission state. ----- See <https://www.w3.org/TR/permissions/#webdriver-command-set-permission>+-- See <https://www.w3.org/TR/permissions/#webdriver-command-set-permission>. setPermission :: (HasCallStack, Marionette m) => m () setPermission = sendCommand_ "WebDriver:SetPermission"  -- | Set the timeouts for page loading, searching, and scripts. ----- See <https://w3c.github.io/webdriver/#set-timeouts>+-- See <https://w3c.github.io/webdriver/#set-timeouts>. setTimeouts :: (HasCallStack, Marionette m) => Timeouts -> m () setTimeouts timeouts =     sendCommand_@@ -725,12 +705,11 @@             , parameters = toJSON timeouts             } --- | Set the position and size of the window on the operating system's--- window manager. The width and height refer to the window's--- @outerWidth@ and @outerHeight@, which include browser chrome and--- OS-level window borders.+-- | Set the position and size of the window on the operating system's window manager.+-- The width and height refer to the window's @outerWidth@ and @outerHeight@, which include+-- browser chrome and OS-level window borders. ----- See <https://w3c.github.io/webdriver/#set-window-rect>+-- See <https://w3c.github.io/webdriver/#set-window-rect>. setWindowRect :: (HasCallStack, Marionette m) => Rect -> m () setWindowRect rect =     sendCommand_@@ -741,7 +720,7 @@  -- | Switch to the given frame within the current window. ----- See <https://w3c.github.io/webdriver/#switch-to-frame>+-- See <https://w3c.github.io/webdriver/#switch-to-frame>. switchToFrame :: (HasCallStack, Marionette m) => Frame -> m () switchToFrame frame =     sendCommand_@@ -750,17 +729,17 @@             , parameters = toJSON frame             } --- | Set the current browsing context for future commands to the parent--- of the current browsing context.+-- | Set the current browsing context for future commands to the parent of the current browsing+-- context. ----- See <https://w3c.github.io/webdriver/#switch-to-parent-frame>+-- See <https://w3c.github.io/webdriver/#switch-to-parent-frame>. switchToParentFrame :: (HasCallStack, Marionette m) => m () switchToParentFrame = sendCommand_ "WebDriver:SwitchToParentFrame"  -- | Switch to the top-level browsing context identified by the given -- window handle. ----- See <https://w3c.github.io/webdriver/#switch-to-window>+-- See <https://w3c.github.io/webdriver/#switch-to-window>. switchToWindow :: (HasCallStack, Marionette m) => WindowHandle -> m () switchToWindow window =     sendCommand_@@ -769,10 +748,9 @@             , parameters = toJSON window             } --- | Take a screenshot of the current frame, returned as a lossless PNG--- image.+-- | Take a screenshot of the current frame as a lossless PNG image. ----- See <https://w3c.github.io/webdriver/#take-screenshot>+-- See <https://w3c.github.io/webdriver/#take-screenshot>. takeScreenshot :: (HasCallStack, Marionette m) => m ByteString takeScreenshot =     Base64.decodeLenient@@ -786,7 +764,7 @@  -- | Add a credential to a virtual authenticator. ----- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-add-credential>+-- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-add-credential>. addCredential :: (HasCallStack, Marionette m) => AuthenticatorId -> Credential -> m () addCredential authenticatorId credential =     sendCommand_@@ -803,7 +781,7 @@  -- | Add a virtual authenticator, returning its identifier. ----- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-add-virtual-authenticator>+-- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-add-virtual-authenticator>. addVirtualAuthenticator :: (HasCallStack, Marionette m) => VirtualAuthenticator -> m AuthenticatorId addVirtualAuthenticator authenticator =     value@@ -815,7 +793,7 @@  -- | Get the credentials stored in a virtual authenticator. ----- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-get-credentials>+-- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-get-credentials>. getCredentials :: (HasCallStack, Marionette m) => AuthenticatorId -> m [Credential] getCredentials authenticatorId =     value@@ -827,7 +805,7 @@  -- | Remove all credentials from a virtual authenticator. ----- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-remove-all-credentials>+-- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-remove-all-credentials>. removeAllCredentials :: (HasCallStack, Marionette m) => AuthenticatorId -> m () removeAllCredentials authenticatorId =     sendCommand_@@ -838,7 +816,7 @@  -- | Remove a credential from a virtual authenticator. ----- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-remove-credential>+-- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-remove-credential>. removeCredential :: (HasCallStack, Marionette m) => AuthenticatorId -> CredentialId -> m () removeCredential authenticatorId credentialId =     sendCommand_@@ -853,7 +831,7 @@  -- | Remove a virtual authenticator. ----- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-remove-virtual-authenticator>+-- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-remove-virtual-authenticator>. removeVirtualAuthenticator :: (HasCallStack, Marionette m) => AuthenticatorId -> m () removeVirtualAuthenticator authenticatorId =     sendCommand_@@ -864,7 +842,7 @@  -- | Set the user-verified flag on a virtual authenticator. ----- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-set-user-verified>+-- See <https://www.w3.org/TR/webauthn-3/#sctn-automation-set-user-verified>. setUserVerified :: (HasCallStack, Marionette m) => AuthenticatorId -> Bool -> m () setUserVerified authenticatorId verified =     sendCommand_
+ src/Test/Marionette/Key.hs view
@@ -0,0 +1,129 @@+module Test.Marionette.Key where++import Data.Aeson (ToJSON (..), Value (String))+import Data.Text qualified as Text+import Prelude hiding (Left, Right)++-- | Key values representing special keys and arbitrary characters, as defined in the+-- <https://www.w3.org/TR/webdriver/#keyboard-actions W3C WebDriver specification>.+data Key+    = Null+    | Cancel+    | Help+    | Backspace+    | Tab+    | Clear+    | Return+    | Enter+    | Shift+    | Control+    | Alt+    | Pause+    | Escape+    | Space+    | PageUp+    | PageDown+    | End+    | Home+    | Left+    | UpArrow+    | Right+    | DownArrow+    | Insert+    | Delete+    | Semicolon+    | Equals+    | Numpad0+    | Numpad1+    | Numpad2+    | Numpad3+    | Numpad4+    | Numpad5+    | Numpad6+    | Numpad7+    | Numpad8+    | Numpad9+    | Multiply+    | Add+    | Separator+    | Subtract+    | Decimal+    | Divide+    | F1+    | F2+    | F3+    | F4+    | F5+    | F6+    | F7+    | F8+    | F9+    | F10+    | F11+    | F12+    | Meta+    | ZenkakuHankaku+    | Key Char+    deriving stock (Show, Eq)++char :: Key -> Char+char Null = '\xE000'+char Cancel = '\xE001'+char Help = '\xE002'+char Backspace = '\xE003'+char Tab = '\xE004'+char Clear = '\xE005'+char Return = '\xE006'+char Enter = '\xE007'+char Shift = '\xE008'+char Control = '\xE009'+char Alt = '\xE00A'+char Pause = '\xE00B'+char Escape = '\xE00C'+char Space = '\xE00D'+char PageUp = '\xE00E'+char PageDown = '\xE00F'+char End = '\xE010'+char Home = '\xE011'+char Left = '\xE012'+char UpArrow = '\xE013'+char Right = '\xE014'+char DownArrow = '\xE015'+char Insert = '\xE016'+char Delete = '\xE017'+char Semicolon = '\xE018'+char Equals = '\xE019'+char Numpad0 = '\xE01A'+char Numpad1 = '\xE01B'+char Numpad2 = '\xE01C'+char Numpad3 = '\xE01D'+char Numpad4 = '\xE01E'+char Numpad5 = '\xE01F'+char Numpad6 = '\xE020'+char Numpad7 = '\xE021'+char Numpad8 = '\xE022'+char Numpad9 = '\xE023'+char Multiply = '\xE024'+char Add = '\xE025'+char Separator = '\xE026'+char Subtract = '\xE027'+char Decimal = '\xE028'+char Divide = '\xE029'+char F1 = '\xE031'+char F2 = '\xE032'+char F3 = '\xE033'+char F4 = '\xE034'+char F5 = '\xE035'+char F6 = '\xE036'+char F7 = '\xE037'+char F8 = '\xE038'+char F9 = '\xE039'+char F10 = '\xE03A'+char F11 = '\xE03B'+char F12 = '\xE03C'+char Meta = '\xE03D'+char ZenkakuHankaku = '\xE040'+char (Key c) = c++instance ToJSON Key where+    toJSON = String . Text.singleton . char
src/Test/Marionette/Protocol.hs view
@@ -104,8 +104,7 @@                         else Left <$> parseJSON err             _ -> mzero --- | An error reported by a Marionette or WebDriver command, as returned by--- the server.+-- | An error reported by a Marionette or WebDriver command, as returned by the server. -- -- See <https://w3c.github.io/webdriver/#errors> data Error = Error@@ -127,4 +126,4 @@     , marionetteProtocol :: Int     }     deriving stock (Generic, Show)-    deriving anyclass (FromJSON)+    deriving anyclass (FromJSON, ToJSON)
src/Test/Marionette/Registry.hs view
@@ -6,8 +6,6 @@ import GHC.Generics (Generic) import Prelude --------------------------------------------------------------------- data RegistryEntry = RegistryEntry     { entryType :: Text     , namespace :: Text
src/Test/Marionette/Selector.hs view
@@ -21,7 +21,7 @@     | ByXPath Text     | Anon Text     | AnonAttribute Text-    deriving stock (Generic, Eq, Show)+    deriving stock (Generic, Eq, Ord, Show)     deriving anyclass (Hashable)  toObject :: Selector -> Object
+ test/ClientSpec.hs view
@@ -0,0 +1,150 @@+module Main where++import Data.Aeson qualified as Aeson+import Data.Binary qualified as Binary+import Network.Simple.TCP+    ( HostPreference (Host)+    , Socket+    , accept+    , closeSock+    , listen+    , sendLazy+    )+import Network.Socket (PortNumber, socketPort)+import Test.Hspec (describe, hspec, it)+import Test.Hspec.Expectations.Lifted (shouldReturn, shouldSatisfy)+import Test.Marionette.Class (sendCommand)+import Test.Marionette.Client+    ( MarionetteTimeout (..)+    , SocketClosed (..)+    , incoming+    , runMarionetteTWith+    )+import Test.Marionette.Protocol+import UnliftIO (TQueue, async, cancel, finally, timeout, try)+import UnliftIO.STM (atomically, readTQueue)+import Prelude++withServer :: (Socket -> TQueue MarionetteMessage -> IO ()) -> (PortNumber -> IO a) -> IO a+withServer serve body =+    listen (Host "127.0.0.1") "0" \(listening, _) -> do+        port <- socketPort listening+        handling <-+            async $ accept listening \(socket, _) ->+                serve socket =<< incoming socket \_ -> pure ()+        body port `finally` cancel handling++send :: (Aeson.ToJSON a) => Socket -> a -> IO ()+send socket = sendLazy socket . Binary.encode . MarionetteMessage . Aeson.encode++recv :: TQueue MarionetteMessage -> IO (Message Command)+recv frames = do+    MarionetteMessage lbs <- atomically $ readTQueue frames+    either fail pure $ Aeson.eitherDecode lbs++greeting :: Greeting+greeting = Greeting{applicationType = "fake", marionetteProtocol = 3}++reply :: Socket -> Message Command -> Result -> IO ()+reply socket Message{messageId} result =+    send socket Message{messageId, messageContent = result}++testCommandTimeout :: Int+testCommandTimeout = 200_000++main :: IO ()+main = hspec do+    describe "incoming" do+        it "reports SocketClosed when the server hangs up" $+            withServer+                ( \socket _frames -> do+                    send socket greeting+                    closeSock socket+                )+                \port -> do+                    outcome <-+                        timeout 2_000_000 . try $+                            runMarionetteTWith+                                "127.0.0.1"+                                port+                                testCommandTimeout+                                (sendCommand @_ @Aeson.Value "noop")++                    outcome `shouldSatisfy` \case+                        Just (Left SocketClosed) -> True+                        _ -> False++    describe "sendCommand" do+        it "throws MarionetteTimeout without killing the session" $+            withServer+                ( \socket frames -> do+                    send socket greeting+                    _silent <- recv frames+                    echoed <- recv frames+                    reply socket echoed $ Right (Aeson.String "ok")+                )+                \port ->+                    runMarionetteTWith "127.0.0.1" port testCommandTimeout do+                        silent <- try $ sendCommand @_ @Aeson.Value "silent"+                        silent `shouldSatisfy` \case+                            Left MarionetteTimeout{} -> True+                            _ -> False+                        sendCommand "echo" `shouldReturn` Aeson.String "ok"++        it "reports connection loss promptly, waking a command already in flight" $+            withServer+                ( \socket frames -> do+                    send socket greeting+                    _abandoned <- recv frames+                    closeSock socket+                )+                \port -> do+                    outcome <-+                        timeout 2_000_000 . try $+                            runMarionetteTWith+                                "127.0.0.1"+                                port+                                testCommandTimeout+                                (sendCommand @_ @Aeson.Value "abandoned")++                    outcome `shouldSatisfy` \case+                        Just (Left SocketClosed) -> True+                        _ -> False++        it "surfaces a server-reported Error rather than a transport failure" $+            withServer+                ( \socket frames -> do+                    send socket greeting+                    command <- recv frames+                    reply socket command $+                        Left Error{error = "no such element", message = "nope", stacktrace = ""}+                )+                \port -> do+                    outcome <-+                        try $+                            runMarionetteTWith+                                "127.0.0.1"+                                port+                                testCommandTimeout+                                (sendCommand @_ @Aeson.Value "find")++                    outcome `shouldSatisfy` \case+                        Left Error{message = "nope"} -> True+                        _ -> False++        it "discards a reply that arrives after its command already timed out" $+            withServer+                ( \socket frames -> do+                    send socket greeting+                    slow <- recv frames+                    echoed <- recv frames -- only received once the client gives up on "slow"+                    reply socket slow $ Right (Aeson.String "too late")+                    reply socket echoed $ Right (Aeson.String "ok")+                )+                \port ->+                    runMarionetteTWith "127.0.0.1" port testCommandTimeout do+                        slow <- try $ sendCommand @_ @Aeson.Value "slow"+                        slow `shouldSatisfy` \case+                            Left MarionetteTimeout{} -> True+                            _ -> False+                        sendCommand "echo" `shouldReturn` Aeson.String "ok"
test/Main.hs view
@@ -1,18 +1,20 @@-{-# OPTIONS_GHC -Wno-orphans #-}- module Main where -import Control.Concurrent (forkIO, killThread)-import Control.Exception (bracket)+import Control.Exception (Exception, bracket, throwIO) import Data.Aeson (toJSON) import Data.ByteString qualified as ByteString import Data.Functor (void)+import Data.Maybe (isNothing) import Data.Text (Text) import Data.Text qualified as Text import Lucid import Network.HTTP.Types (status200)+import Network.Socket (PortNumber) import Network.Wai (Application, rawPathInfo, responseLBS)-import Network.Wai.Handler.Warp (run)+import Network.Wai.Handler.Warp (Port, testWithApplication)+import System.Directory (doesFileExist)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory) import System.Process.Typed     ( checkExitCode     , nullStream@@ -21,13 +23,17 @@     , setStdout     , startProcess     )-import Test.Hspec (describe, hspec, it)+import Test.Hspec (Spec, describe, hspec)+import Test.Hspec qualified as Hspec import Test.Hspec.Expectations.Lifted-import Test.Marionette+import Test.Marionette hiding (navigate)+import Test.Marionette qualified as Marionette import Test.Marionette.Cookie qualified as Cookie+import Test.Marionette.Key qualified as Key import Test.Marionette.Rect qualified as Rect import Test.Marionette.Timeouts qualified as Timeouts import Test.Marionette.WebAuthn qualified as WebAuthn+import UnliftIO.Retry (constantDelay, limitRetriesByCumulativeDelay, retrying) import Prelude hiding (print)  page :: Text -> Html () -> Html ()@@ -69,6 +75,48 @@                     button_ [id_ "alert-btn", onclick_ "alert('Hello!')"] "Alert"                     button_ [id_ "confirm-btn", onclick_ "confirm('Sure?')"] "Confirm"                     button_ [id_ "prompt-btn", onclick_ "prompt('Input:')"] "Prompt"+            "/actions" ->+                page "Actions Page" do+                    style_+                        "\+                        \#hover-target { width: 100px; height: 100px; background: red; }\+                        \#hover-target:hover { background: green; }\+                        \#drag-source { position: absolute; top: 0; left: 0; width: 50px; height: 50px; background: blue; }\+                        \#drop-target { position: absolute; top: 200px; left: 200px; width: 100px; height: 100px; background: yellow; }\+                        \#scroll-container { height: 100px; overflow: auto; }\+                        \#scroll-container div { height: 1000px; }"+                    div_+                        [ id_ "hover-target"+                        , onmouseover_ "this.dataset.hovered = 'true'"+                        , onmouseout_ "this.dataset.hovered = 'false'"+                        ]+                        "Hover me"+                    div_ [id_ "drag-source"] "Drag"+                    div_ [id_ "drop-target"] "Drop"+                    button_ [id_ "shift-click-btn", onclick_ "this.dataset.shift = event.shiftKey"] "Click"+                    div_ [id_ "scroll-container"] (div_ mempty "tall content")+                    script_+                        "\+                        \(function () {\+                        \  const source = document.getElementById('drag-source');\+                        \  const target = document.getElementById('drop-target');\+                        \  let dragging = false;\+                        \  source.addEventListener('pointerdown', () => { dragging = true; });\+                        \  document.addEventListener('pointermove', (e) => {\+                        \    if (!dragging) return;\+                        \    source.style.left = (e.clientX - 25) + 'px';\+                        \    source.style.top = (e.clientY - 25) + 'px';\+                        \  });\+                        \  document.addEventListener('pointerup', () => {\+                        \    if (!dragging) return;\+                        \    dragging = false;\+                        \    const sr = source.getBoundingClientRect();\+                        \    const tr = target.getBoundingClientRect();\+                        \    const overlap = sr.left < tr.right && sr.right > tr.left\+                        \      && sr.top < tr.bottom && sr.bottom > tr.top;\+                        \    target.dataset.dropped = overlap ? 'true' : 'false';\+                        \  });\+                        \})();"             _ ->                 page "Marionette Test" do                     h1_ [id_ "heading"] "Hello, Marionette!"@@ -77,66 +125,86 @@                     div_ [id_ "parent"] do                         span_ [id_ "child"] "Child element" -withTestServer :: IO a -> IO a-withTestServer = bracket (forkIO $ run 8080 app) killThread . const+newtype MarionetteDidNotStart = MarionetteDidNotStart FilePath+    deriving stock (Show)+    deriving anyclass (Exception) -withFirefox :: IO a -> IO a-withFirefox =-    bracket-        ( startProcess-            . setStdout nullStream-            . setStderr nullStream-            . proc "firefox"-            $ [ "--marionette"-              , "--headless"-              , "-remote-allow-system-access"-              ]-        )-        ( \process -> do-            runMarionetteT $ newSession >> quit-            checkExitCode process-        )-        . const+awaitMarionettePort :: FilePath -> IO PortNumber+awaitMarionettePort profile = do+    port <-+        retrying+            (limitRetriesByCumulativeDelay 5_000_000 $ constantDelay 100_000)+            (const $ pure . isNothing)+            (const poll)+    maybe (throwIO $ MarionetteDidNotStart activePortPath) pure port+  where+    activePortPath = profile </> "MarionetteActivePort"+    poll =+        doesFileExist activePortPath >>= \case+            True -> Just . read <$> readFile activePortPath+            False -> pure Nothing -run' :: (HasCallStack) => MarionetteT IO () -> IO ()-run' action = runMarionetteT do-    newSession-    navigate "http://localhost:8080/"-    action-    deleteSession+withFirefox :: (PortNumber -> IO a) -> IO a+withFirefox action =+    withSystemTempDirectory "marionette-test-profile" \profile -> do+        writeFile (profile </> "user.js") "user_pref(\"marionette.port\", 0);\n"+        bracket+            ( do+                process <-+                    startProcess+                        . setStdout nullStream+                        . setStderr nullStream+                        . proc "firefox"+                        $ [ "--profile"+                          , profile+                          , "--marionette"+                          , "--headless"+                          , "-remote-allow-system-access"+                          ]+                port <- awaitMarionettePort profile+                pure (process, port)+            )+            ( \(process, port) -> do+                runMarionetteTWith "localhost" port defaultCommandTimeout $ newSession >> quit+                checkExitCode process+            )+            (action . snd)  main :: IO ()-main = withTestServer . withFirefox . hspec $ do+main = testWithApplication (pure app) $ (withFirefox . (hspec .)) . spec++spec :: Port -> PortNumber -> Spec+spec httpPort marionettePort = describe "Marionette" do     describe "Session" do-        it "can create and delete a session" . run' $ do+        it "can create and delete a session" do             deleteSession             newSession      describe "Navigation" do-        it "navigates to a URL" . run' $ getCurrentURL `shouldReturn` "http://localhost:8080/"+        it "navigates to a URL" $ getCurrentURL `shouldReturn` url "/" -        it "can go back and forward" . run' $ do-            navigate "http://localhost:8080/target"+        it "can go back and forward" do+            navigate "/target"             back-            getCurrentURL `shouldReturn` "http://localhost:8080/"+            getCurrentURL `shouldReturn` url "/"             forward-            getCurrentURL `shouldReturn` "http://localhost:8080/target"+            getCurrentURL `shouldReturn` url "/target" -        it "can refresh" . run' $ do+        it "can refresh" do             refresh-            getCurrentURL `shouldReturn` "http://localhost:8080/"+            getCurrentURL `shouldReturn` url "/" -        it "getTitle returns page title" . run' $ getTitle `shouldReturn` "Marionette Test"+        it "getTitle returns page title" $ getTitle `shouldReturn` "Marionette Test" -        it "getPageSource returns HTML" . run' $ do+        it "getPageSource returns HTML" do             source <- getPageSource             source `shouldSatisfy` Text.isInfixOf "Hello, Marionette!" -        it "getCurrentURL returns current URL" . run' $-            getCurrentURL `shouldReturn` "http://localhost:8080/"+        it "getCurrentURL returns current URL" $+            getCurrentURL `shouldReturn` url "/"      describe "Timeouts" do-        it "setTimeouts / getTimeouts roundtrip" . run' $ do+        it "setTimeouts / getTimeouts roundtrip" do             let t =                     Timeouts                         { script = Just 5000@@ -147,20 +215,20 @@             getTimeouts `shouldReturn` t      describe "Window" do-        it "getWindowHandle returns a handle" . run' $ do+        it "getWindowHandle returns a handle" do             handle <- getWindowHandle             handle `shouldSatisfy` not . null . show -        it "getWindowHandles returns at least one handle" . run' $ do+        it "getWindowHandles returns at least one handle" do             handles <- getWindowHandles             handles `shouldSatisfy` not . null -        it "getWindowRect returns a rect" . run' $ do+        it "getWindowRect returns a rect" do             Rect{..} <- getWindowRect             width `shouldSatisfy` (> 0)             height `shouldSatisfy` (> 0) -        it "setWindowRect / getWindowRect roundtrip" . run' $ do+        it "setWindowRect / getWindowRect roundtrip" do             let r =                     Rect                         { x = 0@@ -171,18 +239,18 @@             setWindowRect r             getWindowRect `shouldReturn` r -        it "maximizeWindow does not throw" $ run' maximizeWindow-        it "minimizeWindow does not throw" $ run' minimizeWindow-        it "fullscreenWindow does not throw" $ run' fullscreenWindow+        it "maximizeWindow does not throw" maximizeWindow+        it "minimizeWindow does not throw" minimizeWindow+        it "fullscreenWindow does not throw" fullscreenWindow -        it "newTab opens a new window handle" . run' $ do+        it "newTab opens a new window handle" do             before <- getWindowHandles             NewWindowResult{..} <- newTab             -- newWindowType `shouldBe` Tab             after <- getWindowHandles             after `shouldBe` before <> [newWindowHandle] -        it "newWindow opens a new window handle" . run' $ do+        it "newWindow opens a new window handle" do             before <- getWindowHandles             NewWindowResult{..} <- newTab             -- This does not seem to work@@ -190,7 +258,7 @@             after <- getWindowHandles             after `shouldBe` before <> [newWindowHandle] -        it "switchToWindow and closeWindow" . run' $ do+        it "switchToWindow and closeWindow" do             original <- getWindowHandle             NewWindowResult{..} <- newTab             switchToWindow newWindowHandle@@ -200,141 +268,141 @@             getWindowHandle `shouldReturn` original      describe "Element finding" do-        it "findElement by id" . run' $ do+        it "findElement by id" do             el <- findElement (ById "heading")             getElementText el `shouldReturn` "Hello, Marionette!" -        it "findElement by class" . run' $ do+        it "findElement by class" do             el <- findElement (ByClass "content")             getElementText el `shouldReturn` "Test paragraph" -        it "findElement by tag" . run' $ do+        it "findElement by tag" do             el <- findElement (ByTag "h1")             getElementText el `shouldReturn` "Hello, Marionette!" -        it "findElement by CSS selector" . run' $ do+        it "findElement by CSS selector" do             el <- findElement (ByCSS "#heading")             getElementText el `shouldReturn` "Hello, Marionette!" -        it "findElement by XPath" . run' $ do+        it "findElement by XPath" do             el <- findElement (ByXPath "//h1[@id='heading']")             getElementText el `shouldReturn` "Hello, Marionette!" -        it "findElements returns multiple elements" . run' $ do+        it "findElements returns multiple elements" do             els <- findElements (ByClass "content")             length els `shouldBe` 2 -        it "findElementFrom finds child within parent" . run' $ do+        it "findElementFrom finds child within parent" do             parent <- findElement (ById "parent")             child <- findElementFrom parent (ById "child")             getElementText child `shouldReturn` "Child element" -        it "findElementsFrom finds children within parent" . run' $ do+        it "findElementsFrom finds children within parent" do             parent <- findElement (ById "parent")             children :: [Element] <- findElementsFrom parent (ByTag "span")             length children `shouldBe` 1 -        it "findElement by link text" . run' $ do-            navigate "http://localhost:8080/link"+        it "findElement by link text" do+            navigate "/link"             el <- findElement (ByLinkText "Click me")             getElementText el `shouldReturn` "Click me" -        it "findElement by partial link text" . run' $ do-            navigate "http://localhost:8080/link"+        it "findElement by partial link text" do+            navigate "/link"             el <- findElement (ByPartialLinkText "Click here")             text <- getElementText el             text `shouldSatisfy` Text.isInfixOf "Click here"      describe "Element properties" do-        it "getElementAttribute" . run' $ do+        it "getElementAttribute" do             el <- findElement (ById "para")             getElementAttribute "class" el `shouldReturn` Just "content" -        it "getElementProperty" . run' $ do+        it "getElementProperty" do             el <- findElement (ById "para")             getElementProperty el "id" `shouldReturn` Just "para" -        it "getElementTagName" . run' $ do+        it "getElementTagName" do             el <- findElement (ById "heading")             getElementTagName el `shouldReturn` "h1" -        it "getElementRect" . run' $ do+        it "getElementRect" do             el <- findElement (ById "heading")             Rect{..} <- getElementRect el             width `shouldSatisfy` (> 0) -        it "getElementCSSValue" . run' $ do+        it "getElementCSSValue" do             el <- findElement (ById "heading")             val <- getElementCSSValue el "display"             val `shouldSatisfy` not . null . show -        it "getComputedRole" . run' $ do+        it "getComputedRole" do             el <- findElement (ById "heading")             role <- getComputedRole el             role `shouldSatisfy` not . Text.null -        it "getComputedLabel" . run' $ do-            navigate "http://localhost:8080/form"+        it "getComputedLabel" do+            navigate "/form"             el <- findElement (ById "submit")             label <- getComputedLabel el             label `shouldSatisfy` not . Text.null -        it "isElementDisplayed" . run' $ do-            navigate "http://localhost:8080/"+        it "isElementDisplayed" do+            navigate "/"             el <- findElement (ById "heading")             isElementDisplayed el `shouldReturn` True -        it "isElementEnabled" . run' $ do-            navigate "http://localhost:8080/form"+        it "isElementEnabled" do+            navigate "/form"             el <- findElement (ById "text-input")             isElementEnabled el `shouldReturn` True -        it "isElementSelected for unchecked checkbox" . run' $ do-            navigate "http://localhost:8080/form"+        it "isElementSelected for unchecked checkbox" do+            navigate "/form"             el <- findElement (ById "checkbox")             isElementSelected el `shouldReturn` False -        it "getActiveElement does not throw" . run' . void $ getActiveElement+        it "getActiveElement does not throw" $ void getActiveElement      describe "Element interaction" do-        it "elementClick navigates via link" . run' $ do-            navigate "http://localhost:8080/link"+        it "elementClick navigates via link" do+            navigate "/link"             el <- findElement (ById "link")             elementClick el-            getCurrentURL `shouldReturn` "http://localhost:8080/target"+            getCurrentURL `shouldReturn` url "/target" -        it "elementSendKeys fills input" . run' $ do-            navigate "http://localhost:8080/form"+        it "elementSendKeys fills input" do+            navigate "/form"             el <- findElement (ById "text-input")             elementSendKeys el "hello"             getElementProperty el "value" `shouldReturn` Just "hello" -        it "elementClear clears input" . run' $ do-            navigate "http://localhost:8080/form"+        it "elementClear clears input" do+            navigate "/form"             el <- findElement (ById "text-input")             elementSendKeys el "hello"             elementClear el             getElementProperty el "value" `shouldReturn` Just ""      describe "Script execution" do-        it "executeScript returns a value" . run' $ do+        it "executeScript returns a value" $             executeScript "return 1 + 1" [] `shouldReturn` (2 :: Int) -        it "executeScript can access elements" . run' $ do-            navigate "http://localhost:8080/"+        it "executeScript can access elements" do+            navigate "/"             executeScript                 "return document.getElementById(arguments[0]).textContent"                 [toJSON @Text "heading"]                 `shouldReturn` ("Hello, Marionette!" :: Text) -        it "executeAsyncScript returns a value" . run' $ do+        it "executeAsyncScript returns a value" do             executeAsyncScript @_ @_ @Int                 "const callback = arguments[arguments.length - 1]; setTimeout(() => callback(arguments[0]), 100)"                 [toJSON @Int 42]                 `shouldReturn` Just 42      describe "Cookies" do-        it "addCookie / getCookies roundtrip" . run' $ do+        it "addCookie / getCookies roundtrip" do             deleteAllCookies             let cookie =                     Cookie@@ -350,7 +418,7 @@             cookies <- getCookies             cookies `shouldSatisfy` any \c -> Cookie.name c == "test" -        it "deleteCookie removes a cookie" . run' $ do+        it "deleteCookie removes a cookie" do             deleteAllCookies             let cookie =                     Cookie@@ -367,67 +435,67 @@             cookies <- getCookies             cookies `shouldNotSatisfy` any \c -> Cookie.name c == "test" -        it "deleteAllCookies removes all cookies" . run' $ do+        it "deleteAllCookies removes all cookies" do             deleteAllCookies             getCookies `shouldReturn` []      describe "Alerts" do-        it "acceptAlert dismisses an alert" . run' $ do-            navigate "http://localhost:8080/alert"+        it "acceptAlert dismisses an alert" do+            navigate "/alert"             el <- findElement (ById "alert-btn")             elementClick el             getAlertText `shouldReturn` "Hello!"             acceptAlert -        it "dismissAlert dismisses a confirm dialog" . run' $ do-            navigate "http://localhost:8080/alert"+        it "dismissAlert dismisses a confirm dialog" do+            navigate "/alert"             el <- findElement (ById "confirm-btn")             elementClick el             dismissAlert -        it "sendAlertText fills a prompt" . run' $ do-            navigate "http://localhost:8080/alert"+        it "sendAlertText fills a prompt" do+            navigate "/alert"             el <- findElement (ById "prompt-btn")             elementClick el             sendAlertText "my input"             acceptAlert      describe "Frames" do-        it "switchToParentFrame does not throw" . run' $ switchToParentFrame+        it "switchToParentFrame does not throw" switchToParentFrame      describe "Context" do-        it "getContext returns a context" . run' $ do+        it "getContext returns a context" do             ctx <- getContext             ctx `shouldSatisfy` (`elem` [minBound .. maxBound]) -        it "setContext / getContext roundtrip" . run' $ do+        it "setContext / getContext roundtrip" do             setContext ContentContext             getContext `shouldReturn` ContentContext             setContext ChromeContext             getContext `shouldReturn` ChromeContext      describe "Shadow DOM" do-        it "getShadowRoot / findElementFromShadowRoot" . run' $ do-            navigate "http://localhost:8080/shadow"+        it "getShadowRoot / findElementFromShadowRoot" do+            navigate "/shadow"             host <- findElement (ById "host")             shadow <- getShadowRoot host             el <- findElementFromShadowRoot shadow (ByCSS "#shadow-p")             getElementText el `shouldReturn` "Shadow content" -        it "findElementsFromShadowRoot" . run' $ do-            navigate "http://localhost:8080/shadow"+        it "findElementsFromShadowRoot" do+            navigate "/shadow"             host <- findElement (ById "host")             shadow <- getShadowRoot host             els <- findElementsFromShadowRoot shadow (ByCSS "p")             length els `shouldBe` 1      describe "Screenshots" do-        it "takeScreenshot returns non-empty bytes" . run' $ do+        it "takeScreenshot returns non-empty bytes" do             bytes <- takeScreenshot             ByteString.length bytes `shouldNotBe` 0      describe "WebAuthn" do-        it "addVirtualAuthenticator / getCredentials roundtrip" . run' $ do+        it "addVirtualAuthenticator / getCredentials roundtrip" do             let opts =                     VirtualAuthenticator                         { protocol = "ctap2"@@ -443,7 +511,7 @@             getCredentials aid `shouldReturn` []             removeVirtualAuthenticator aid -        it "setUserVerified does not throw" . run' $ do+        it "setUserVerified does not throw" do             let opts =                     VirtualAuthenticator                         { protocol = "ctap2"@@ -460,7 +528,7 @@             setUserVerified aid True             removeVirtualAuthenticator aid -        it "removeAllCredentials does not throw" . run' $ do+        it "removeAllCredentials does not throw" do             let opts =                     VirtualAuthenticator                         { protocol = "ctap2"@@ -475,3 +543,79 @@             aid <- addVirtualAuthenticator opts             removeAllCredentials aid             removeVirtualAuthenticator aid++    describe "Actions" do+        it "hovering dispatches mouseover/mouseout and real CSS :hover state" do+            navigate "/actions"+            target <- findElement (ById "hover-target")+            getElementAttribute "data-hovered" target `shouldReturn` Nothing+            getElementCSSValue target "background-color" `shouldReturn` Just "rgb(255, 0, 0)"+            performActions [mouse [PointerMove 0 (FromElement target) 0 0]]+            getElementAttribute "data-hovered" target `shouldReturn` Just "true"+            getElementCSSValue target "background-color" `shouldReturn` Just "rgb(0, 128, 0)"+            performActions [mouse [PointerMove 0 Viewport 0 0]]+            getElementAttribute "data-hovered" target `shouldReturn` Just "false"+            getElementCSSValue target "background-color" `shouldReturn` Just "rgb(255, 0, 0)"+            releaseActions++        it "drags an element onto a drop target" do+            navigate "/actions"+            source <- findElement (ById "drag-source")+            target <- findElement (ById "drop-target")+            getElementAttribute "data-dropped" target `shouldReturn` Nothing+            performActions+                [ mouse+                    [ PointerMove 0 (FromElement source) 0 0+                    , PointerDown LeftButton+                    , PointerMove 200 (FromElement target) 0 0+                    , PointerUp LeftButton+                    ]+                ]+            getElementAttribute "data-dropped" target `shouldReturn` Just "true"+            releaseActions++        it "combines a held key and a pointer click into one gesture" do+            navigate "/actions"+            button <- findElement (ById "shift-click-btn")+            performActions+                [ keyboard [KeyDown Key.Shift]+                , mouse+                    [ PointerMove 0 (FromElement button) 0 0+                    , PointerDown LeftButton+                    , PointerUp LeftButton+                    ]+                ]+            getElementAttribute "data-shift" button `shouldReturn` Just "true"+            releaseActions+            elementClick button+            getElementAttribute "data-shift" button `shouldReturn` Just "false"++        it "releaseActions lets go of a key held with no matching keyUp" do+            navigate "/actions"+            button <- findElement (ById "shift-click-btn")+            performActions [keyboard [KeyDown Key.Shift]]+            releaseActions+            elementClick button+            getElementAttribute "data-shift" button `shouldReturn` Just "false"++        it "scrolls a container via a wheel action" do+            navigate "/actions"+            container <- findElement (ById "scroll-container")+            (before :: Double) <- executeScript "return arguments[0].scrollTop" [toJSON container]+            before `shouldBe` 0+            -- A zero duration reliably does not scroll in headless Firefox,+            -- unlike pointer moves/clicks, which work fine at duration 0.+            performActions [wheel [Scroll 100 (FromElement container) 0 0 0 200]]+            (after :: Double) <- executeScript "return arguments[0].scrollTop" [toJSON container]+            after `shouldSatisfy` (> 0)+  where+    url path = "http://localhost:" <> Text.show httpPort <> path++    navigate = Marionette.navigate . url++    it :: (HasCallStack) => String -> MarionetteT IO () -> Spec+    it label action = Hspec.it label $ runMarionetteTWith "localhost" marionettePort defaultCommandTimeout do+        newSession+        navigate "/"+        action+        deleteSession