From e920fc769545e67a49934ab71bb53c511f6bf0ba Mon Sep 17 00:00:00 2001 From: Corey Innis Date: Mon, 30 Sep 2024 13:12:49 -0400 Subject: [PATCH] SDK: add `!` pipelining 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`. --- lib/playwright/browser_context.ex | 1 + lib/playwright/sdk/channel_owner.ex | 7 +- lib/playwright/sdk/pipeline.ex | 170 ++++++++++++++++++ test/api/browser_context/add_cookies_test.exs | 15 ++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 lib/playwright/sdk/pipeline.ex diff --git a/lib/playwright/browser_context.ex b/lib/playwright/browser_context.ex index cf747df7..cf02f8a0 100644 --- a/lib/playwright/browser_context.ex +++ b/lib/playwright/browser_context.ex @@ -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) diff --git a/lib/playwright/sdk/channel_owner.ex b/lib/playwright/sdk/channel_owner.ex index 56007373..904cd9b7 100644 --- a/lib/playwright/sdk/channel_owner.ex +++ b/lib/playwright/sdk/channel_owner.ex @@ -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 @@ -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 @@ -194,7 +195,7 @@ defmodule Playwright.SDK.ChannelOwner do end defmacro @expr do - quote do + quote(location: :keep) do Kernel.@(unquote(expr)) end end diff --git a/lib/playwright/sdk/pipeline.ex b/lib/playwright/sdk/pipeline.ex new file mode 100644 index 00000000..c23b0e83 --- /dev/null +++ b/lib/playwright/sdk/pipeline.ex @@ -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 diff --git a/test/api/browser_context/add_cookies_test.exs b/test/api/browser_context/add_cookies_test.exs index f50f8b7e..4853748b 100644 --- a/test/api/browser_context/add_cookies_test.exs +++ b/test/api/browser_context/add_cookies_test.exs @@ -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