Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions integration_test/cases/browser/permissions_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
defmodule Wallabidi.Integration.Browser.PermissionsTest do
use Wallabidi.Integration.SessionCase, async: false
@moduletag :browser

test "grant_permissions lets getUserMedia succeed without a real prompt", %{session: session} do

Check failure on line 5 in integration_test/cases/browser/permissions_test.exs

View workflow job for this annotation

GitHub Actions / Chrome CDP (1.20.0/28.4.1)

test grant_permissions lets getUserMedia succeed without a real prompt (Wallabidi.Integration.Browser.PermissionsTest)

Check failure on line 5 in integration_test/cases/browser/permissions_test.exs

View workflow job for this annotation

GitHub Actions / Chrome CDP (1.19.x/28.x)

test grant_permissions lets getUserMedia succeed without a real prompt (Wallabidi.Integration.Browser.PermissionsTest)

Check failure on line 5 in integration_test/cases/browser/permissions_test.exs

View workflow job for this annotation

GitHub Actions / Chrome CDP (1.18.x/27.x)

test grant_permissions lets getUserMedia succeed without a real prompt (Wallabidi.Integration.Browser.PermissionsTest)

Check failure on line 5 in integration_test/cases/browser/permissions_test.exs

View workflow job for this annotation

GitHub Actions / Chrome BiDi (1.18.x/27.x)

test grant_permissions lets getUserMedia succeed without a real prompt (Wallabidi.Integration.Browser.PermissionsTest)

Check failure on line 5 in integration_test/cases/browser/permissions_test.exs

View workflow job for this annotation

GitHub Actions / Chrome BiDi (1.20.0/28.4.1)

test grant_permissions lets getUserMedia succeed without a real prompt (Wallabidi.Integration.Browser.PermissionsTest)

Check failure on line 5 in integration_test/cases/browser/permissions_test.exs

View workflow job for this annotation

GitHub Actions / Chrome BiDi (1.19.x/28.x)

test grant_permissions lets getUserMedia succeed without a real prompt (Wallabidi.Integration.Browser.PermissionsTest)
page = visit(session, "/")

execute_script(page, """
window.__result = "pending";
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then(() => { window.__result = "granted"; })
.catch(e => { window.__result = "denied:" + e.name; });
""")

before_grant = poll_result(session)
assert before_grant == "denied:NotAllowedError"

assert :ok = grant_permissions(session, [:camera, :microphone])

execute_script(page, """
window.__result = "pending";
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then(s => { window.__result = "granted:" + s.getTracks().map(t => t.kind).sort().join(","); })
.catch(e => { window.__result = "denied:" + e.name; });
""")

after_grant = poll_result(session)
assert after_grant == "granted:audio,video"
end

defp poll_result(session, attempts \\ 20) do
{:ok, result} = Wallabidi.Remote.CDP.Client.evaluate(session, "window.__result")

cond do
result != "pending" -> result
attempts <= 0 -> flunk("window.__result never resolved")
true -> Process.sleep(200) && poll_result(session, attempts - 1)
end
end
end
22 changes: 22 additions & 0 deletions lib/wallabidi/browser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,28 @@ defmodule Wallabidi.Browser do

defp remove_illegal_characters(string), do: String.replace(string, ~r{<>:"/\\\?\*}, "")

@doc """
Grants media permissions (`:camera`, `:microphone`) for `session`, so a
page's `getUserMedia`/`getDisplayMedia` calls succeed without a real
permission prompt — useful for driving a headless session into a
WebRTC call. Applies to every origin in the session's browser context.

Pair with launching Chrome with a fake camera/mic
(`--use-fake-device-for-media-stream`) — this grants the permission;
the launch flags give `getUserMedia` an actual device to open. See the
[Recording guide](recording.html) for a Chrome image built for this.

CDP-only (`driver: :chrome_cdp`); other drivers raise
`Wallabidi.DriverError`.

```elixir
:ok = Wallabidi.Browser.grant_permissions(session, [:camera, :microphone])
```
"""
@spec grant_permissions(session, [:camera | :microphone]) :: :ok | {:error, term}
def grant_permissions(%{driver: driver} = session, permissions) when is_list(permissions),
do: driver.grant_permissions(session, permissions)

@doc """
Gets the window handle of the current window.

Expand Down
7 changes: 7 additions & 0 deletions lib/wallabidi/driver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ defmodule Wallabidi.Driver do
"""
@callback take_screenshot(Session.t() | Element.t()) :: binary | {:error, reason}

@doc """
Grants media permissions (camera/microphone) so `getUserMedia` calls in
the page succeed without a real permission prompt. CDP-only today —
drivers without support raise `Wallabidi.DriverError`.
"""
@callback grant_permissions(Session.t(), [:camera | :microphone]) :: :ok | {:error, reason}

@doc """
Invoked to get the handle for the currently focused window.
"""
Expand Down
8 changes: 8 additions & 0 deletions lib/wallabidi/exceptions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,21 @@ end
defmodule Wallabidi.DriverError do
defexception [:message]

@doc "Convenience for the common case: unsupported on the in-process LiveView driver."
def not_supported(operation) do
%__MODULE__{
message:
"#{operation} is not supported by the LiveView driver. " <>
"Tag this test with @tag :browser to run it with a browser driver."
}
end

@doc "Unsupported on `driver_module` specifically (e.g. a capability only Chrome CDP implements)."
def not_supported(operation, driver_module) do
%__MODULE__{
message: "#{operation} is not supported by #{inspect(driver_module)}."
}
end
end

defmodule Wallabidi.NavigationError do
Expand Down
4 changes: 4 additions & 0 deletions lib/wallabidi/live_view/driver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,10 @@ defmodule Wallabidi.LiveView.Driver do
def send_keys(_, _), do: raise(Wallabidi.DriverError.not_supported("send_keys/2"))
@impl true
def take_screenshot(_), do: raise(Wallabidi.DriverError.not_supported("take_screenshot/1"))
@impl true
def grant_permissions(_, _),
do: raise(Wallabidi.DriverError.not_supported("grant_permissions/2"))

@impl true
def accept_alert(_, _), do: raise(Wallabidi.DriverError.not_supported("accept_alert/2"))
@impl true
Expand Down
46 changes: 46 additions & 0 deletions lib/wallabidi/remote/cdp/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,52 @@ defmodule Wallabidi.Remote.CDP.Client do
end
end

# ----- Media permissions -----

@permission_types %{camera: "videoCapture", microphone: "audioCapture"}

@doc """
Grants media permissions (`:camera`, `:microphone`) for the session's
browser context, so `getUserMedia`/`getDisplayMedia` calls in the page
succeed without a real permission prompt — headless Chrome has no UI
surface to show or auto-accept one.

Applies to every origin in the session's browser context (CDP's
`Browser.grantPermissions` with no `origin` given), since a session
navigating between origins — or joining a call on a domain not known in
advance — is the common case here, not a single already-known origin.

Pairs with launching Chrome with a fake camera/mic (`--use-fake-device-for-media-stream`,
optionally with `--use-file-for-fake-video-capture=`/`--use-file-for-fake-audio-capture=`)
— this call satisfies the permission prompt; the launch flags are what
give `getUserMedia` an actual (synthetic) device to open. Wallabidi
doesn't manage Chrome's launch flags; see the
[Recording guide](recording.html) for a Chrome image built for this.
"""
@spec grant_permissions(Session.t(), [:camera | :microphone]) :: :ok | {:error, term}
def grant_permissions(%Session{} = session, permissions) when is_list(permissions) do
cdp_permissions =
Enum.map(permissions, fn permission ->
Map.get(@permission_types, permission) ||
raise ArgumentError,
"unknown permission #{inspect(permission)} — expected one of #{inspect(Map.keys(@permission_types))}"
end)

browser_context_id = get_in(session.capabilities, [:browser_context_id])

params =
if browser_context_id do
%{permissions: cdp_permissions, browserContextId: browser_context_id}
else
%{permissions: cdp_permissions}
end

case cdp_send(session, "Browser.grantPermissions", params) do
{:ok, _} -> :ok
error -> error
end
end

# ----- Screenshot + window size -----

@doc "Capture a PNG screenshot of the current viewport. Returns raw binary."
Expand Down
4 changes: 4 additions & 0 deletions lib/wallabidi/remote/driver/generic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ defmodule Wallabidi.Remote.Driver.Generic do
{:set_cookie, 3, true},
{:set_cookie, 4, true},
{:take_screenshot, 1, true},
{:grant_permissions, 2, true},
{:get_window_size, 1, true},
{:set_window_size, 3, true},
{:click, 1, true},
Expand Down Expand Up @@ -153,6 +154,9 @@ defmodule Wallabidi.Remote.Driver.Generic do
def take_screenshot(%Element{} = element),
do: Orchestrator.take_screenshot(spec(element), element)

def grant_permissions(%Session{} = session, permissions),
do: Orchestrator.grant_permissions(spec(session), session, permissions)

def get_window_size(parent), do: Orchestrator.get_window_size(spec(parent), parent)

def set_window_size(parent, w, h),
Expand Down
16 changes: 16 additions & 0 deletions lib/wallabidi/remote/driver/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,22 @@ defmodule Wallabidi.Remote.Driver.Orchestrator do
take_screenshot(spec, Element.root_session(element))
end

@doc """
Grants media permissions. CDP only — see
`Wallabidi.Remote.CDP.Client.grant_permissions/2`.

Unconditional delegation: `spec.wire_protocol` is
`Wallabidi.Remote.CDP.Client` on BOTH Chrome CDP and Lightpanda (they
share the same CDP façade), so this must never be reached for
Lightpanda — `LightpandaCDP.grant_permissions/2` overrides the
`Generic` delegate to raise before dispatch gets here. Don't gate this
on `function_exported?/3`; it can't distinguish the two drivers.
"""
@spec grant_permissions(Spec.t(), Session.t(), [:camera | :microphone]) ::
:ok | {:error, term}
def grant_permissions(%Spec{} = spec, %Session{} = session, permissions),
do: spec.wire_protocol.grant_permissions(session, permissions)

@doc "List cookies for the session's current origin."
@spec cookies(Spec.t(), Session.t()) :: {:ok, list(map)} | {:error, term}
def cookies(%Spec{} = spec, %Session{} = session), do: spec.wire_protocol.cookies(session)
Expand Down
6 changes: 6 additions & 0 deletions lib/wallabidi/remote/drivers/chrome_bidi.ex
Original file line number Diff line number Diff line change
Expand Up @@ -198,5 +198,11 @@ defmodule Wallabidi.Remote.Drivers.ChromeBiDi do
def send_keys(%Wallabidi.Element{} = element, keys),
do: Wallabidi.Remote.Driver.Generic.send_keys(element, keys)

# grant_permissions: CDP-only for now — BiDiClient has no equivalent
# to CDP's Browser.grantPermissions wired up. See
# Wallabidi.Remote.CDP.Client.grant_permissions/2.
def grant_permissions(%Session{}, _permissions),
do: raise(Wallabidi.DriverError.not_supported("grant_permissions/2", __MODULE__))

defdelegate parse_log(log), to: Wallabidi.Remote.Chrome.Logger
end
9 changes: 9 additions & 0 deletions lib/wallabidi/remote/drivers/lightpanda_cdp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -322,4 +322,13 @@ defmodule Wallabidi.Remote.Drivers.LightpandaCDP do

def send_keys(%Element{} = element, keys),
do: Wallabidi.Remote.Driver.Generic.send_keys(element, keys)

# grant_permissions: Lightpanda has no camera/mic or getUserMedia
# support, and would otherwise silently dispatch through the SAME
# wire_protocol module Chrome CDP uses (both point at
# Wallabidi.Remote.CDP.Client) — Generic's delegate can't tell the two
# drivers apart, so this must be overridden here rather than gated in
# Orchestrator.
def grant_permissions(%Session{}, _permissions),
do: raise(Wallabidi.DriverError.not_supported("grant_permissions/2", __MODULE__))
end
19 changes: 19 additions & 0 deletions test/wallabidi/browser_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ defmodule Wallabidi.BrowserTest do
defp visits do
Agent.get(__MODULE__, fn visits -> visits end)
end

def grant_permissions(%Session{}, permissions), do: {:ok, permissions}
end

describe "visit/2" do
Expand Down Expand Up @@ -143,6 +145,23 @@ defmodule Wallabidi.BrowserTest do
end
end

describe "grant_permissions/2" do
test "delegates to the driver" do
session = session_for_driver(TestDriver)

assert Browser.grant_permissions(session, [:camera, :microphone]) ==
{:ok, [:camera, :microphone]}
end

test "raises on a driver that doesn't support it (e.g. LiveView)" do
session = %Session{driver: Wallabidi.LiveView.Driver}

assert_raise Wallabidi.DriverError, fn ->
Browser.grant_permissions(session, [:camera])
end
end
end

defp session_for_driver(driver) do
%Session{driver: driver}
end
Expand Down
14 changes: 14 additions & 0 deletions test/wallabidi/remote/cdp/client_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule Wallabidi.Remote.CDP.ClientTest do
use ExUnit.Case, async: true

alias Wallabidi.Remote.CDP.Client, as: CDPClient
alias Wallabidi.Session

describe "grant_permissions/2" do
test "raises ArgumentError for an unknown permission before touching the transport" do
assert_raise ArgumentError, ~r/unknown permission :geolocation/, fn ->
CDPClient.grant_permissions(%Session{}, [:geolocation])
end
end
end
end
30 changes: 30 additions & 0 deletions test/wallabidi/remote/driver/permissions_dispatch_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Wallabidi.Remote.Driver.PermissionsDispatchTest do
use ExUnit.Case, async: true

# Regression coverage for the same class of bug caught while building
# open_stream/1: Chrome CDP and Lightpanda share the exact same
# `wire_protocol` module (`Wallabidi.Remote.CDP.Client`), so a CDP-only
# capability can't be gated in the Orchestrator via `function_exported?/3`
# — it can't tell the two drivers apart. LightpandaCDP and ChromeBiDi
# must both override the Generic delegate directly so dispatch never
# reaches Orchestrator / CDP.Client for either.

alias Wallabidi.Remote.Drivers.{ChromeBiDi, LightpandaCDP}
alias Wallabidi.Session

describe "LightpandaCDP" do
test "grant_permissions/2 raises Wallabidi.DriverError without touching the transport" do
assert_raise Wallabidi.DriverError, ~r/grant_permissions\/2 is not supported/, fn ->
LightpandaCDP.grant_permissions(%Session{}, [:camera])
end
end
end

describe "ChromeBiDi" do
test "grant_permissions/2 raises Wallabidi.DriverError without touching the transport" do
assert_raise Wallabidi.DriverError, ~r/grant_permissions\/2 is not supported/, fn ->
ChromeBiDi.grant_permissions(%Session{}, [:camera])
end
end
end
end
Loading