Skip to content

Commit

Permalink
SDK: add ! pipelining
Browse files Browse the repository at this point in the history
This change provides part 1 of changes in support of pipelining API functions: where functions without the `!` suffix might return `{:error, error}`, those with the `!` will raise a `RuntimeError`.

Note that the underlying implementation is essentially a copy of that found in [`Unsafe`](https://github.com/whitfin/unsafe/tree/main). Minor adjustments have been made to integrate more closely with Playwright, and match desired usage and execution patterns.

An initial usage, for demonstration purposes, may be found at `BrowserContext.add_cookies/2`.
  • Loading branch information
coreyti committed Sep 30, 2024
1 parent 26fed18 commit e920fc7
Show file tree
Hide file tree
Showing 4 changed files with 190 additions and 3 deletions.
1 change: 1 addition & 0 deletions lib/playwright/browser_context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ defmodule Playwright.BrowserContext do
| `:secure` | `boolean()` | *(optional)* |
| `:sameSite` | `binary()` | *(optional)* one of "Strict", "Lax", "None" |
"""
@pipe {:add_cookies, [:context, :cookies]}
@spec add_cookies(t(), [cookie]) :: t()
def add_cookies(context, cookies)

Expand Down
7 changes: 4 additions & 3 deletions lib/playwright/sdk/channel_owner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ defmodule Playwright.SDK.ChannelOwner do
defmacro __using__(_args) do
Module.register_attribute(__CALLER__.module, :properties, accumulate: true)

quote do
quote(location: :keep) do
use Playwright.SDK.Pipeline
import Kernel, except: [@: 1]
import unquote(__MODULE__), only: [@: 1]
end
Expand All @@ -162,7 +163,7 @@ defmodule Playwright.SDK.ChannelOwner do
Module.put_attribute(module, :properties, arg)
doc = Keyword.get(options, :doc, false)

quote do
quote(location: :keep) do
@doc unquote(doc)
@spec unquote(arg)(t()) :: term()
def unquote(arg)(owner) do
Expand Down Expand Up @@ -194,7 +195,7 @@ defmodule Playwright.SDK.ChannelOwner do
end

defmacro @expr do
quote do
quote(location: :keep) do
Kernel.@(unquote(expr))
end
end
Expand Down
170 changes: 170 additions & 0 deletions lib/playwright/sdk/pipeline.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
defmodule Playwright.SDK.Pipeline do
@moduledoc false

# NOTE: This module is essentially copy of `Unsafe.Generator`, with the
# following minor modifications per Playwright style wishes:
#
# - Remap `@unsafe` as `@pipe`
# - Embed `unwrap/1` and `unwrap/2` handlers
# - Render customized `@doc` description
#
# See [`Unsafe`](https://hexdocs.pm/unsafe/Unsafe.html) for the original.
defmodule Compiler do
@moduledoc false

# definitions for multiple arities
@type arities :: arity | [arity]

# definitions for function definition bindings
@type binding :: {atom, arities} | {atom, arities, handler}

# definitions for handler functions
@type handler :: atom | {atom, atom}

@doc false
@spec compile!(Macro.Env.t(), binding | [binding], Keyword.t()) :: Macro.t()
def compile!(env, bindings, options) when is_list(bindings),
do: Enum.map(bindings, &compile!(env, &1, options))

# This will fire if the provided binding does not contain a
# valid handler. If this is the case, the handler option will
# be pulled from the binding options and passed through as the
# handler to use for the binding (without any validation).
def compile!(env, {name, arity}, options),
do: compile!(env, {name, arity, options[:handler]}, options)

# This definition fires when the provided binding includes a list of
# arities and unpacks them into a list of bindings per arity. It then
# just passes the results through to the same compile!/3 function
# in order to make use of the same processing as any other bindings.
def compile!(env, {name, [head | _] = arity, handler}, options)
when is_integer(head) do
arity
|> Enum.map(&{name, &1, handler})
|> Enum.map(&compile!(env, &1, options))
end

# This is the main definition which will compile a binding into a new
# unsafe function handle, ready to be included in a module at compile
# time. Arguments are generated based on the arity list provided and
# passed to the main function reference, before being passed through
# a validated handler and being returned from the unsafe function.
def compile!(env, {name, arity, handler}, options) do
# determine whether we have arguments or arity
{enum, length, generator} =
if is_list(arity) do
# use an accepted arguments list with provided name definitions
{arity, length(arity), &Macro.var(&1, env.module)}
else
# create an argument list based on the arity; [ arg0, arg1, etc... ]
{0..(arity && arity - 1), arity, &Macro.var(:"arg#{&1}", env.module)}
end

# generate the parameters used to define the proxy
params = Enum.map(enum, generator)

# create a definition for the proxy; apply(env.module, name, params)
result =
quote do: apply(unquote(env.module), unquote(name), unquote(params))

# generate the handler definition
handle =
case handler do
# private function names as atoms
func when is_atom(func) and not is_nil(func) ->
# can be through of as func(result)
quote do: unquote(func)(unquote(result))

# public functions
{mod, func} ->
# can be thought of as apply(mod, func, [ result ])
quote do: apply(unquote(mod), unquote(func), [unquote(result)])

# bad definitions
_fail ->
raise CompileError,
description: "Invalid handler definition for #{name}/#{length}",
file: env.file,
line: env.line
end

# generate documentation tags
ex_docs =
if options[:docs] do
# use a forwarding documentation message based on the function definition
quote do:
@doc(
"Pipeline proxy for `#{unquote(name)}/#{unquote(length)}`. Instead of returning `{:error, error}`, `#{unquote(name)}/#{unquote(length)}!` will raise a `RuntimeError`."
)
else
# disable documentation
quote do: @doc(false)
end

# compile
quote do
# unpack the docs
unquote(ex_docs)

# add the function definition and embed the handle inside
def unquote(:"#{name}!")(unquote_splicing(params)) do
unquote(handle)
end
end
end

# Finally, if this definition fires, the provided definition
# for the binding was totally invalid and should cause errors.
def compile!(env, _invalid, _options),
do:
raise(CompileError,
description: "Invalid function reference provided",
file: env.file,
line: env.line
)
end

defmodule Generator do
@moduledoc false
alias Compiler
alias Generator

# Hook for the `use` syntax, which will automatically configure
# the calling module to use the attributes required for generation.
defmacro __using__(options) do
quote location: :keep do
@before_compile Generator
@pipeline_options unquote(options)

Module.register_attribute(__MODULE__, :pipe, accumulate: true)
end
end

# The compiler hook which will invoke the main compilation phase
# found in `Unsafe.Compiler.compile/3` to compile the definitions.
defmacro __before_compile__(%{module: module} = env) do
binding = Module.get_attribute(module, :pipe)

options =
module
|> Module.get_attribute(:pipeline_options)
|> Kernel.||([])

Compiler.compile!(env, binding, options)
end
end

defmacro __using__(_) do
quote location: :keep do
use Playwright.SDK.Pipeline.Generator, docs: true, handler: :unwrap

defp unwrap({:error, error}) do
raise(RuntimeError, message: error.message)
end

defp unwrap(result) do
result
end
end
end
end
15 changes: 15 additions & 0 deletions test/api/browser_context/add_cookies_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,19 @@ defmodule Playwright.BrowserContext.AddCookiesTest do
# test_should_set_cookies_for_a_frame
# test_should_not_block_third_party_cookies
end

describe "BrowserContext.add_cookies!/2" do
test "on success, returns 'subject", %{assets: assets, page: page} do
context = Page.owned_context(page)
cookies = [%{url: assets.empty, name: "password", value: "123456"}]
assert %BrowserContext{} = BrowserContext.add_cookies(context, cookies)
end

test "on failure, raises `RuntimeError`", %{page: page} do
assert_raise RuntimeError, "cookies[0].name: expected string, got undefined", fn ->
context = Page.owned_context(page)
BrowserContext.add_cookies!(context, [%{bogus: "cookie"}])
end
end
end
end

0 comments on commit e920fc7

Please sign in to comment.