packages feed

webdriver-precore-0.2.0.0: src/WebDriverPreCore/HTTP/Protocol.hs

{-| 
This module defines the 'Command' type and related types/functions for HTTP commands referenced by the API functions in "WebDriverPreCore.HTTP.API" 

"WebDriverPreCore.HTTP.API" contains functions that generate the payload for each HTTP command and is the main interface for users of this package to interact with the WebDriver HTTP protocol.

An example of using these modules to implement a basic HTTP client can be found in the [test repository](https://github.com/pyrethrum/webdriver/tree/main/webdriver-precore/test#readme) for this package.
-}

module WebDriverPreCore.HTTP.Protocol
  (

    -- * Command
    Command (..),

    -- ** Constructors
    {-| The following constructors are utility functions that partially applied in "WebDriverPreCore.HTTP.API" to generate specific named command functions for POST requests. 
        Although these functions form the basis of many commands in the "WebDriverPreCore.HTTP.API", it would be unusual to need to use these directly.
    -}
    mkPost,
    mkPost',

    -- ** Fallback Constructors
    {-| The following constructors are provided to modify or create new commands that are not directly supported by the "WebDriverPreCore.HTTP.API".
        These constructors provide a means by which users can work around defects in this package or defects in driver implementation as well as handling driver specific extensions to the HTTP protocol. 
    -}
    voidCommand,
    loosenCommand,
    coerceCommand,
    extendPost,
    extendPostLoosen,

    -- * Capabilities
    FullCapabilities (..),
    Capabilities (..),
    UnhandledPromptBehavior (..),
    PageLoadStrategy (..),
    BrowserName (..),
    PlatformName (..),
    Proxy (..),
    VendorSpecific (..),
    SocksProxy (..),
    Timeouts (..),
    PerfLoggingPrefs (..),
    MobileEmulation (..),
    LogLevel (..),
    LogSettings (..),
    DeviceMetrics (..),
     -- * Capability Utility Functions
    alwaysMatchCapabilities,
    minCapabilities,
    minFullCapabilities,
    minFirefoxCapabilities,
    minChromeCapabilities,

    -- * Error
    module WebDriverPreCore.Error,

    -- * Core Types
    Cookie (..),
    Status (..),
    ElementId (..),
    ShadowRootElementId (..),
    FrameReference (..),
    HandleType (..),
    SameSite (..),
    Script (..),
    Selector (..),
    Session (..),
    SessionResponse (..),
    Handle (..),
    WindowHandleSpec (..),
    WindowRect (..),
    module Url,

    -- * Action Types
    Action (..),
    Actions (..),
    KeyAction (..),
    Pointer (..),
    PointerAction (..),
    PointerOrigin (..),
    WheelAction (..),
  )
where

import AesonUtils (nonEmpty, opt, parseObject)
import Data.Aeson as A
  ( FromJSON (..),
    Key,
    KeyValue ((.=)),
    ToJSON (toJSON),
    Value (..),
    object,
    withObject,
    withText,
    (.:),
    (.:?),
  )
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Parser)
import Data.Function ((&))
import Data.Map.Strict qualified as M
import Data.Maybe (catMaybes)
import Data.Set (Set, fromList, notMember)
import Data.Text (Text, pack, unpack)
import Data.Text qualified as T
import Data.Word (Word16)
import GHC.Generics (Generic)
import Utils (txt)
import WebDriverPreCore.Error
import WebDriverPreCore.HTTP.Capabilities
import WebDriverPreCore.HTTP.Command
import WebDriverPreCore.Internal.HTTPBidiCommon as Url (URL (..))
import Prelude hiding (id)

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#dfn-get-window-handle)
newtype Handle = MkHandle {handle :: Text}
  deriving (Show, Eq, Generic)

instance ToJSON Handle where
  toJSON :: Handle -> Value
  toJSON (MkHandle handle) = object ["handle" .= handle]

instance FromJSON Handle where
  parseJSON :: Value -> Parser Handle
  parseJSON = \case
    String t -> pure $ MkHandle t
    Object o -> do
      h <- o .: "handle"
      pure $ MkHandle h
    v -> fail $ unpack $ "Expected Handle as String or Object with handle property, got: " <> txt v

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#new-window)
data WindowHandleSpec = HandleSpec
  { handle :: Handle,
    handletype :: HandleType
  }
  deriving (Show, Eq)

instance ToJSON WindowHandleSpec where
  toJSON :: WindowHandleSpec -> Value
  toJSON HandleSpec {handle, handletype} =
    object
      [ "handle" .= handle.handle,
        "type" .= handletype
      ]

instance FromJSON WindowHandleSpec where
  parseJSON :: Value -> Parser WindowHandleSpec
  parseJSON = withObject "WindowHandleSpec" $ \v -> do
    handle <- MkHandle <$> v .: "handle"
    handletype <- v .: "type"
    pure $ HandleSpec {..}

data HandleType
  = Window
  | Tab
  deriving (Show, Eq)

instance ToJSON HandleType where
  toJSON :: HandleType -> Value
  toJSON = String . T.toLower . pack . show

instance FromJSON HandleType where
  parseJSON :: Value -> Parser HandleType
  parseJSON = withText "HandleType" $ \case
    "window" -> pure Window
    "tab" -> pure Tab
    v -> fail $ unpack $ "Unknown HandleType " <> v

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#dfn-find-element)
newtype ElementId = MkElement {id :: Text}
  deriving (Show, Eq, Generic)

instance ToJSON ElementId where
  toJSON :: ElementId -> Value
  toJSON (MkElement id) = object [elementFieldName .= id]

instance FromJSON ElementId where
  parseJSON :: Value -> Parser ElementId
  parseJSON =
    withObject "ElementId" $
      fmap MkElement . (.: elementFieldName)

newtype ShadowRootElementId = MkShadowRootElementId {id :: Text}
  deriving (Show, Eq, Generic)

instance ToJSON ShadowRootElementId where
  toJSON :: ShadowRootElementId -> Value
  toJSON (MkShadowRootElementId id) = object [shadowRootFieldName .= id]

instance FromJSON ShadowRootElementId where
  parseJSON :: Value -> Parser ShadowRootElementId
  parseJSON =
    withObject "ElementId" $
      fmap MkShadowRootElementId . (.: shadowRootFieldName)

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#dfn-new-sessions)
newtype Session = MkSession {id :: Text}
  deriving (Show, Eq, Generic)

data SessionResponse = MkSessionResponse
  { sessionId :: Session,
    webSocketUrl :: Maybe Text,
    capabilities :: Capabilities,
    extensions :: Maybe (M.Map Text Value)
  }
  deriving (Show, Eq, Generic)

webSocketKey :: Key
webSocketKey = "webSocketUrl"

data Script = MkScript
  { script :: Text,
    args :: [Value]
  }
  deriving (Show, Eq, Generic)

instance ToJSON Script

instance ToJSON SessionResponse where
  toJSON :: SessionResponse -> Value
  toJSON MkSessionResponse {sessionId, webSocketUrl, capabilities, extensions} =
    object $
      [ "sessionId" .= sessionId.id,
        "capabilities" .= mergedCaps,
        webSocketKey .= webSocketUrl
      ]
    where
      capsVal = toJSON capabilities
      mergedCaps = extensions & maybe capsVal mergeExtensions
      mergeExtensions :: M.Map Text Value -> Value
      mergeExtensions mv =
        case capsVal of
          Object capsObj -> Object . KM.union capsObj . KM.fromMapText $ mv
          -- this will never happen - capabilities is always an Object
          _ -> error "SessionResponse - toJSON: capabilities must be an Object"

instance FromJSON SessionResponse where
  parseJSON :: Value -> Parser SessionResponse
  parseJSON =
    withObject
      "SessionResponse.value"
      ( \valueObj -> do
          sessionId <- MkSession <$> valueObj .: "sessionId"
          --
          capabilitiesVal' :: Value <- valueObj .: "capabilities"
          allCapsObject <- parseObject "capabilities property returned from newSession should be an object" capabilitiesVal'
          webSocketUrl <- allCapsObject .:? webSocketKey
          -- webSocketUrl will come back as a url but is is a Bool flag in Capabilities
          -- so it must be converted or there will be a parse error
          let capabilitiesVal = webSocketUrlToBool allCapsObject
          capabilities :: Capabilities <- parseJSON $ Object capabilitiesVal
          standardCapsProps <- parseObject "JSON frotim Capabilities Object must be a JSON Object" $ toJSON capabilities
          let keys = fromList . KM.keys
              capsKeys = keys standardCapsProps
              nonNullExtensionKey k v = k `notMember` capsKeys && k /= webSocketKey && nonEmpty v
              extensionsMap = KM.toMapText $ KM.filterWithKey nonNullExtensionKey $ allCapsObject
              extensions =
                if null extensionsMap
                  then Nothing
                  else Just extensionsMap

          pure $ MkSessionResponse {sessionId, webSocketUrl, capabilities, extensions}
      )

webSocketUrlToBool :: KM.KeyMap Value -> KM.KeyMap Value
webSocketUrlToBool o =
  case KM.lookup webSocketKey o of
    Just (String url) ->
      if (T.null url)
        then
          KM.delete webSocketKey o
        else
          KM.insert webSocketKey (Bool True) o -- change to Bool
    _ -> o -- no change if property not present

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#dfn-status)
data Status = MkStatus
  { ready :: Bool,
    message :: Text
  }
  deriving (Show, Eq, Generic)

instance ToJSON Status

instance FromJSON Status

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#cookies)
data SameSite
  = Lax
  | Strict
  | None
  deriving (Show, Eq, Ord, Generic)

instance FromJSON SameSite

instance ToJSON SameSite where
  toJSON :: SameSite -> Value
  toJSON = String . txt

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#dfn-switch-to-frame)
data FrameReference
  = TopLevelFrame
  | FrameNumber Word16
  | FrameElementId ElementId
  deriving (Show, Eq)

instance ToJSON FrameReference where
  toJSON :: FrameReference -> Value
  toJSON fr =
    object
      ["id" .= toJSON (frameVariant fr)]
    where
      frameVariant =
        \case
          TopLevelFrame -> Null
          FrameNumber n -> Number $ fromIntegral n
          FrameElementId elm -> object [elementFieldName .= elm.id]

-- https://www.w3.org/TR/webdriver2/#elements
elementFieldName :: Key
elementFieldName = "element-6066-11e4-a52e-4f735466cecf"

-- https://www.w3.org/TR/webdriver2/#shadow-root
shadowRootFieldName :: Key
shadowRootFieldName = "shadow-6066-11e4-a52e-4f735466cecf"

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#cookies)
data Cookie = MkCookie
  { name :: Text,
    value :: Text,
    -- optional
    path :: Maybe Text,
    domain :: Maybe Text,
    secure :: Maybe Bool,
    httpOnly :: Maybe Bool,
    sameSite :: Maybe SameSite,
    -- When the cookie expires, specified in seconds since Unix Epoch.
    expiry :: Maybe Int
  }
  deriving (Show, Eq)

instance FromJSON Cookie where
  parseJSON :: Value -> Parser Cookie
  parseJSON = withObject "Cookie" $ \v -> do
    name <- v .: "name"
    value <- v .: "value"
    path <- v .:? "path"
    domain <- v .:? "domain"
    secure <- v .:? "secure"
    httpOnly <- v .:? "httpOnly"
    sameSite <- v .:? "sameSite"
    expiry <- v .:? "expiry"
    pure MkCookie {..}

instance ToJSON Cookie where
  toJSON :: Cookie -> Value
  toJSON MkCookie {name, value, path, domain, secure, httpOnly, sameSite, expiry} =
    object $
      [ "name" .= name,
        "value" .= value
      ]
        <> catMaybes
          [ opt "path" path,
            opt "domain" domain,
            opt "secure" secure,
            opt "httpOnly" httpOnly,
            opt "sameSite" sameSite,
            opt "expiry" expiry
          ]

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#locator-strategies)
data Selector
  = CSS Text
  | XPath Text
  | LinkText Text
  | PartialLinkText Text
  | TagName Text
  deriving (Show, Eq)

instance ToJSON Selector where
  toJSON :: Selector -> Value
  toJSON = \case
    CSS css -> sJSON "css selector" css
    XPath xpath -> sJSON "xpath" xpath
    LinkText lt -> sJSON "link text" lt
    PartialLinkText plt -> sJSON "partial link text" plt
    TagName tn -> sJSON "tag name" tn
    where
      sJSON using value = object ["using" .= using, "value" .= value]

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#dfn-get-element-rect)
data WindowRect = Rect
  { x :: Int,
    y :: Int,
    width :: Int,
    height :: Int
  }
  deriving (Show, Eq, Generic)

instance ToJSON WindowRect

instance FromJSON WindowRect where
  parseJSON :: Value -> Parser WindowRect
  parseJSON = withObject "WindowRect" $ \v -> do
    x <- v .: "x"
    y <- v .: "y"
    width <- v .: "width"
    height <- v .: "height"
    pure
      Rect
        { x = floor x,
          y = floor y,
          width = floor width,
          height = floor height
        }

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#actions)
newtype Actions = MkActions {actions :: [Action]}
  deriving (Show, Eq, Generic)

instance ToJSON Actions where
  toJSON :: Actions -> Value
  toJSON MkActions {actions} =
    object
      [ "actions" .= actions
      ]

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#actions)
data KeyAction
  = PauseKey {duration :: Maybe Int} -- ms
  | KeyDown
      { value :: Text
      }
  | KeyUp
      { value :: Text
      }
  deriving (Show, Eq)

instance ToJSON KeyAction where
  toJSON :: KeyAction -> Value
  toJSON PauseKey {duration} =
    object $
      [ "type" .= ("pause" :: Text)
      ]
        <> catMaybes [opt "duration" duration]
  toJSON KeyDown {value} =
    object
      [ "type" .= ("keyDown" :: Text),
        "value" .= String value
      ]
  toJSON KeyUp {value} =
    object
      [ "type" .= ("keyUp" :: Text),
        "value" .= String value
      ]

-- Pointer subtypes

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#actions)
data Pointer
  = Mouse
  | Pen
  | Touch
  deriving (Show, Eq)

mkLwrTxt :: (Show a) => a -> Value
mkLwrTxt = String . T.toLower . txt

instance ToJSON Pointer where
  toJSON :: Pointer -> Value
  toJSON = mkLwrTxt

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#actions)
data PointerOrigin
  = Viewport
  | OriginPointer
  | OriginElement ElementId
  deriving (Show, Eq)

instance ToJSON PointerOrigin where
  toJSON :: PointerOrigin -> Value
  toJSON = \case
    Viewport -> "viewport"
    OriginPointer -> "pointer"
    OriginElement (MkElement id') -> object ["element" .= id']

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#actions)
data Action
  = NoneAction
      { id :: Text,
        -- the numeric id of the pointing device. This is a positive integer, with the values 0 and 1 reserved for mouse-type pointers.
        noneActions :: [Maybe Int] -- delay
      }
  | Key
      { id :: Text,
        keyActions :: [KeyAction]
        -- https://github.com/jlipps/simple-wd-spec?tab=readme-ov-file#perform-actions
        -- keys codepoint https://www.w3.org/TR/webdriver2/#keyboard-actions
      }
  | Pointer
      { id :: Text,
        subType :: Pointer,
        -- the numeric id of the pointing device. This is a positive integer, with the values 0 and 1 reserved for mouse-type pointers.
        pointerId :: Int,
        pressed :: Set Int, -- pressed buttons
        x :: Int, -- start x location in viewport coordinates.
        y :: Int, -- start y location in viewport coordinates
        actions :: [PointerAction]
      }
  | Wheel
      { id :: Text,
        wheelActions :: [WheelAction]
      }
  deriving (Show, Eq)

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#actions)
data WheelAction
  = PauseWheel {duration :: Maybe Int} -- ms
  | Scroll
      { origin :: PointerOrigin,
        x :: Int,
        y :: Int,
        deltaX :: Int,
        deltaY :: Int,
        duration :: Maybe Int -- ms
      }
  deriving (Show, Eq)

instance ToJSON WheelAction where
  toJSON :: WheelAction -> Value
  toJSON wa =
    object $ base <> catMaybes [opt "duration" wa.duration]
    where
      base = case wa of
        PauseWheel _ -> ["type" .= ("pause" :: Text)]
        Scroll
          { origin,
            x,
            y,
            deltaX,
            deltaY
          } ->
            [ "type" .= ("scroll" :: Text),
              "origin" .= origin,
              "x" .= x,
              "y" .= y,
              "deltaX" .= deltaX,
              "deltaY" .= deltaY
            ]

-- | [spec](https://www.w3.org/TR/2025/WD-webdriver2-20251028/#actions)
data PointerAction
  = PausePointer {duration :: Maybe Int} -- ms
  | Up
      { button :: Int,
        width :: Maybe Int,
        height :: Maybe Int,
        pressure :: Maybe Float, -- 0 -> 1
        tangentialPressure :: Maybe Float, -- -1 -> 1
        tiltX :: Maybe Int, -- -90 -> 90
        tiltY :: Maybe Int, -- -90 -> 90
        twist :: Maybe Int, -- 0 -> 359
        altitudeAngle :: Maybe Double, -- 0 -> pi/2
        azimuthAngle :: Maybe Double -- 0 -> 2pi-- button} -- button
      }
  | Down
      { button :: Int,
        width :: Maybe Int,
        height :: Maybe Int,
        pressure :: Maybe Float, -- 0 -> 1
        tangentialPressure :: Maybe Float, -- -1 -> 1
        tiltX :: Maybe Int, -- -90 -> 90
        tiltY :: Maybe Int, -- -90 -> 90
        twist :: Maybe Int, -- 0 -> 359
        altitudeAngle :: Maybe Double, -- 0 -> pi/2
        azimuthAngle :: Maybe Double -- 0 -> 2pi-- button
      }
  | Move
      { origin :: PointerOrigin,
        duration :: Maybe Int, -- ms
        -- where to move to
        -- though the spec seems to indicate width and height are double
        -- gecko driver was blowing up with anything other than int
        width :: Maybe Int,
        height :: Maybe Int,
        pressure :: Maybe Float, -- 0 -> 1
        tangentialPressure :: Maybe Float, -- -1 -> 1
        tiltX :: Maybe Int, -- -90 -> 90
        tiltY :: Maybe Int, -- -90 -> 90
        twist :: Maybe Int, -- 0 -> 359
        altitudeAngle :: Maybe Double, -- 0 -> pi/2
        azimuthAngle :: Maybe Double, -- 0 -> 2pi
        x :: Int,
        y :: Int
      }
  | -- looks like not supported yet by gecko driver 02-02-2025
    -- https://searchfox.org/mozilla-central/source/remote/shared/webdriver/Actions.sys.mjs#2340
    Cancel
  deriving (Show, Eq)

instance ToJSON PointerAction where
  toJSON :: PointerAction -> Value
  toJSON = \case
    PausePointer d ->
      object $
        ["type" .= ("pause" :: Text)]
          <> catMaybes [opt "duration" d]
    Up
      { -- https://www.w3.org/TR/pointerevents/#dom-pointerevent-pointerid
        button,
        width, -- magnitude on the X axis), in CSS pixels (see [CSS21]) -- default = 1
        height, -- (magnitude on the Y axis), in CSS pixels (see [CSS21]) -- default = 1
        pressure, -- 0 - 1
        tangentialPressure, -- -1 -> 1
        tiltX, -- -90 -> 90
        tiltY, -- -90 -> 90
        twist, -- 0 -> 359
        altitudeAngle, -- 0 -> pi/2
        azimuthAngle -- 0 -> 2pi
      } ->
        object $
          [ "type" .= ("pointerUp" :: Text),
            "button" .= button
          ]
            <> catMaybes
              [ opt "height" height,
                opt "width" width,
                opt "pressure" pressure,
                opt "tangentialPressure" tangentialPressure,
                opt "tiltX" tiltX,
                opt "tiltY" tiltY,
                opt "twist" twist,
                opt "altitudeAngle" altitudeAngle,
                opt "azimuthAngle" azimuthAngle
              ]
    Down
      { button,
        width,
        height,
        pressure,
        tangentialPressure, -- -1 -> 1
        tiltX, -- -90 -> 90
        tiltY, -- -90 -> 90
        twist, -- 0 -> 359
        altitudeAngle, -- 0 -> pi/2
        azimuthAngle -- 0 -> 2pi
      } ->
        object $
          [ "type" .= ("pointerDown" :: Text),
            "button" .= button
          ]
            <> catMaybes
              [ opt "height" height,
                opt "width" width,
                opt "pressure" pressure,
                opt "tangentialPressure" tangentialPressure,
                opt "tiltX" tiltX,
                opt "tiltY" tiltY,
                opt "twist" twist,
                opt "altitudeAngle" altitudeAngle,
                opt "azimuthAngle" azimuthAngle
              ]
    Move
      { origin,
        duration,
        width,
        height,
        pressure,
        tangentialPressure, -- -1 -> 1
        tiltX, -- -90 -> 90
        tiltY, -- -90 -> 90
        twist, -- 0 -> 359
        altitudeAngle, -- 0 -> pi/2
        azimuthAngle, -- 0 -> 2pi
        x,
        y
      } ->
        object $
          [ "type" .= ("pointerMove" :: Text),
            "origin" .= origin,
            "x" .= x,
            "y" .= y
          ]
            <> catMaybes
              [ opt "duration" duration,
                opt "height" height,
                opt "width" width,
                opt "pressure" pressure,
                opt "tangentialPressure" tangentialPressure,
                opt "tiltX" tiltX,
                opt "tiltY" tiltY,
                opt "twist" twist,
                opt "altitudeAngle" altitudeAngle,
                opt "azimuthAngle" azimuthAngle
              ]
    -- looks like Cancel not supported yet by gecko driver 02-02-2025
    -- https://searchfox.org/mozilla-central/source/remote/shared/webdriver/Actions.sys.mjs#2340
    Cancel -> object ["type" .= ("pointerCancel" :: Text)]

mkPause :: Maybe Int -> Value
mkPause d = object $ ["type" .= ("pause" :: Text)] <> catMaybes [opt "duration" d]

instance ToJSON Action where
  toJSON :: Action -> Value
  toJSON = \case
    NoneAction
      { id,
        noneActions
      } ->
        object
          [ "type" .= ("none" :: Text),
            "id" .= id,
            "actions" .= (mkPause <$> noneActions)
          ]
    Key {id, keyActions} ->
      object
        [ "id" .= id,
          "type" .= ("key" :: Text),
          "actions" .= keyActions
        ]
    Pointer
      { subType,
        actions,
        pointerId,
        pressed,
        id,
        x,
        y
      } ->
        object
          [ "id" .= id,
            "type" .= ("pointer" :: Text),
            "subType" .= subType,
            "pointerId" .= pointerId,
            "pressed" .= pressed,
            "x" .= x,
            "y" .= y,
            "actions" .= actions
          ]
    Wheel {id, wheelActions} ->
      object
        [ "id" .= id,
          "type" .= ("wheel" :: Text),
          "actions" .= wheelActions
        ]