Repository: bitcrowd/chromic_pdf
Pull request #254 (2023-05-23 08:58:42)

Refactor Connection module to allow different strategy
This patch refactors the `Connection` module and surrounding modules to allow different connection strategies (inet) to be implemented. Notably, the `Connection` server has been removed.

- Move `next_call_id` handling to `Channel`
  - For this, make the Protocol `run` function return the call instead of dispatching it directly, and make it only go a single step. Recurse from `Channel` until all calls are done instead.
- Move graceful shutdown handling to `Channel`
- Make `Connection` dumb itself
- Add `Connection.Local` GenServer around the port for local Chrome

Relates to #249


Patch:
diff --git a/lib/chromic_pdf/pdf/browser/channel.ex b/lib/chromic_pdf/pdf/browser/channel.ex
index 80c6fe3..ff3d57b 100644
--- a/lib/chromic_pdf/pdf/browser/channel.ex
+++ b/lib/chromic_pdf/pdf/browser/channel.ex
@@ -7,6 +7,7 @@ defmodule ChromicPDF.Browser.Channel do
   require Logger
   alias ChromicPDF.Browser.ExecutionError
   alias ChromicPDF.{Connection, Protocol}
+  alias ChromicPDF.JsonRPC
 
   # ------------- API ----------------
 
@@ -45,9 +46,14 @@ defmodule ChromicPDF.Browser.Channel do
 
   @impl GenServer
   def init(args) do
-    {:ok, conn} = Connection.start_link(self(), args)
-
-    {:ok, %{conn: conn, waitlist: []}}
+    {:ok, conn_pid} = Connection.start_link(args)
+
+    {:ok,
+     %{
+       conn_pid: conn_pid,
+       waitlist: [],
+       next_call_id: 1
+     }}
   end
 
   # Starts protocol processing, asynchronously sends result message when done.
@@ -68,12 +74,25 @@ defmodule ChromicPDF.Browser.Channel do
 
   # Data packets coming in from connection.
   @impl GenServer
-  def handle_info({:chrome_message, msg}, state) do
+  def handle_info({:msg, msg}, state) do
+    msg = JsonRPC.decode(msg)
+
     warn_on_inspector_crash(msg)
 
     {:noreply, handle_chrome_message(msg, state)}
   end
 
+  @impl GenServer
+  def terminate(:normal, _state), do: :ok
+
+  def terminate(:shutdown, %{conn_pid: conn_pid, next_call_id: next_call_id}) do
+    # Graceful shutdown: Dispatch the Browser.close call to Chrome which will cause it to detach
+    # all debugging sessions and close the port.
+    Connection.send_msg(conn_pid, JsonRPC.encode({"Browser.close", %{}}, next_call_id))
+
+    :ok
+  end
+
   defp warn_on_inspector_crash(msg) do
     if match?(%{"method" => "Inspector.targetCrashed"}, msg) do
       Logger.error("""
@@ -99,14 +118,24 @@ defmodule ChromicPDF.Browser.Channel do
   # -------- Task execution ----------
 
   # "Runs" the protocol processing until done or await instruction reached.
-  defp handle_run_protocol(task, %{conn: conn} = state) do
-    case Protocol.run(task.protocol, &Connection.dispatch_call(conn, &1)) do
+  defp handle_run_protocol(task, %{conn_pid: conn_pid, next_call_id: next_call_id} = state) do
+    {protocol, result} = Protocol.step(task.protocol, next_call_id)
+
+    # For simplicity, we update next_call_id regardless of whether call was sent.
+    task = %{task | protocol: protocol}
+    state = %{state | next_call_id: next_call_id + 1}
+
+    case result do
+      {:call, call} ->
+        :ok = Connection.send_msg(conn_pid, JsonRPC.encode(call, next_call_id))
+        handle_run_protocol(task, state)
+
+      :await ->
+        push_to_waitlist(state, task)
+
       {:halt, result} ->
         GenServer.reply(task.from, result)
         state
-
-      {:await, protocol} ->
-        push_to_waitlist(state, %{task | protocol: protocol})
     end
   end
 
diff --git a/lib/chromic_pdf/pdf/chrome_runner.ex b/lib/chromic_pdf/pdf/chrome_runner.ex
index 388bed2..e249692 100644
--- a/lib/chromic_pdf/pdf/chrome_runner.ex
+++ b/lib/chromic_pdf/pdf/chrome_runner.ex
@@ -3,24 +3,12 @@
 defmodule ChromicPDF.ChromeRunner do
   @moduledoc false
 
-  @behaviour ChromicPDF.Connection.ChromeRunner
-
-  @impl true
-  def spawn(opts) do
+  @spec port_open(keyword()) :: port()
+  def port_open(opts) do
     port_opts = append_if([:binary], :nouse_stdio, !discard_stderr?(opts))
     port_cmd = shell_command("--remote-debugging-pipe", opts)
 
-    port = Port.open({:spawn, port_cmd}, port_opts)
-    Port.monitor(port)
-
-    {:ok, port}
-  end
-
-  @impl true
-  def send_msg(port, msg) do
-    send(port, {self(), {:command, msg <> "\0"}})
-
-    :ok
+    Port.open({:spawn, port_cmd}, port_opts)
   end
 
   @spec warm_up(keyword()) :: {:ok, binary()}
diff --git a/lib/chromic_pdf/pdf/connection.ex b/lib/chromic_pdf/pdf/connection.ex
index 345574f..e719677 100644
--- a/lib/chromic_pdf/pdf/connection.ex
+++ b/lib/chromic_pdf/pdf/connection.ex
@@ -3,134 +3,54 @@
 defmodule ChromicPDF.Connection do
   @moduledoc false
 
-  use GenServer
-  alias ChromicPDF.Connection.{ConnectionLostError, JsonRPC, Tokenizer}
+  @type state :: map()
+  @type msg :: binary()
 
-  defmodule ChromeRunner do
-    @moduledoc false
+  @callback start_link(keyword()) :: {:ok, pid()}
+  @callback handle_init(keyword()) :: {:ok, state()}
+  @callback handle_msg(msg(), state()) :: :ok
 
-    @callback spawn(keyword()) :: {:ok, port()}
-    @callback send_msg(port(), msg :: binary()) :: :ok
-  end
-
-  @type state :: %{
-          port: port(),
-          parent_pid: pid(),
-          tokenizer: Tokenizer.t(),
-          next_call_id: pos_integer()
-        }
-
-  # ------------- API ----------------
-
-  @spec start_link(pid(), keyword()) :: GenServer.on_start()
-  def start_link(parent_pid, opts) do
-    GenServer.start_link(__MODULE__, {parent_pid, opts})
-  end
-
-  @spec dispatch_call(pid(), binary()) :: :ok
-  def dispatch_call(pid, msg) do
-    GenServer.call(pid, {:dispatch_call, msg})
-  end
-
-  @spec port_info(pid) :: keyword()
-  def port_info(pid) do
-    GenServer.call(pid, :port_info)
-  end
-
-  # ------------ Server --------------
-
-  @impl GenServer
-  def init({parent_pid, opts}) do
-    chrome_runner = Keyword.get(opts, :chrome_runner, ChromicPDF.ChromeRunner)
-
-    {:ok, port} = spawn_chrome(chrome_runner, opts)
-
-    Process.flag(:trap_exit, true)
-
-    state = %{
-      chrome_runner: chrome_runner,
-      port: port,
-      parent_pid: parent_pid,
-      tokenizer: Tokenizer.init(),
-      next_call_id: 1
-    }
+  @spec start_link(keyword()) :: GenServer.on_start()
+  def start_link(opts) do
+    strategy = Keyword.get(opts, :connection_strategy, ChromicPDF.Connection.Local)
 
-    {:ok, state}
+    strategy.start_link(opts)
   end
 
-  defp spawn_chrome(chrome_runner, opts) do
-    opts
-    |> Keyword.take([:chrome_args, :discard_stderr, :no_sandbox, :chrome_executable])
-    |> chrome_runner.spawn()
+  @spec send_msg(pid(), binary()) :: :ok
+  def send_msg(pid, msg) do
+    :ok = GenServer.cast(pid, {:msg, msg})
   end
 
-  @impl GenServer
-  def handle_call(
-        {:dispatch_call, call},
-        _from,
-        %{chrome_runner: chrome_runner, port: port, next_call_id: call_id} = state
-      ) do
-    chrome_runner.send_msg(port, JsonRPC.encode(call, call_id))
+  defmacro __using__(_) do
+    quote do
+      use GenServer
+      alias ChromicPDF.Connection
 
-    {:reply, call_id, %{state | next_call_id: call_id + 1}}
-  end
-
-  def handle_call(:port_info, _from, %{port: port} = state) do
-    {:reply, Port.info(port), state}
-  end
-
-  @impl GenServer
-  # Message from Chrome through the port.
-  def handle_info({_port, {:data, data}}, state) do
-    {msgs, tokenizer} = Tokenizer.tokenize(data, state.tokenizer)
-
-    for msg <- msgs do
-      send(state.parent_pid, {:chrome_message, JsonRPC.decode(msg)})
-    end
-
-    {:noreply, %{state | tokenizer: tokenizer}}
-  end
-
-  # Message triggered by Port.monitor/1.
-  # This is unlikely to happen outside terminate/2 (:shutdown).
-  def handle_info({:DOWN, _ref, :port, _port, _exit_state}, state) do
-    {:noreply, state}
-  end
+      @behaviour Connection
 
-  # EXIT signal from port process since we trap signals.
-  def handle_info({:EXIT, _port, _reason}, state) do
-    # Chrome has crashed or was terminated externally.
-    {:stop, :connection_lost, state}
-  end
+      @impl Connection
+      def start_link(opts) do
+        GenServer.start_link(__MODULE__, {self(), opts})
+      end
 
-  @impl GenServer
-  def terminate(:normal, _state), do: :ok
+      @impl GenServer
+      def init({channel_pid, opts}) do
+        {:ok, state} = handle_init(opts)
 
-  def terminate(:connection_lost, _state) do
-    raise(ConnectionLostError, """
-    Chrome has stopped or was terminated by an external program.
+        {:ok, Map.put(state, :channel_pid, channel_pid)}
+      end
 
-    If this happened while you were printing a PDF, this may be a problem with Chrome itelf.
-    If this happens at startup and you are running inside a Docker container with a Linux-based
-    image, please see the "Chrome Sandbox in Docker containers" section of the documentation.
-
-    Either way, to see Chrome's error output, configure ChromicPDF with the option
-
-        discard_stderr: false
-    """)
-  end
+      @impl GenServer
+      def handle_cast({:msg, msg}, state) do
+        :ok = handle_msg(msg, state)
 
-  def terminate(:shutdown, %{chrome_runner: chrome_runner, port: port, next_call_id: call_id}) do
-    # Graceful shutdown: Dispatch the Browser.close call to Chrome which will cause it to detach
-    # all debugging sessions and close the port.
-    chrome_runner.send_msg(port, JsonRPC.encode({"Browser.close", %{}}, call_id))
+        {:noreply, state}
+      end
 
-    # We can't enter the GenServer loop from here, so we need to manually receive the message
-    # about the port going down. In case Chrome takes longer than the configured supervision
-    # shutdown time, we'll receive a :brutal_kill and exit immediately, so no need for a timeout.
-    receive do
-      {:DOWN, _ref, :port, _port, _exit_state} ->
-        :ok
+      defp send_msg_to_channel(msg, %{channel_pid: channel_pid} = state) do
+        send(channel_pid, {:msg, msg})
+      end
     end
   end
 end
diff --git a/lib/chromic_pdf/pdf/connection/local.ex b/lib/chromic_pdf/pdf/connection/local.ex
new file mode 100644
index 0000000..682f274
--- /dev/null
+++ b/lib/chromic_pdf/pdf/connection/local.ex
@@ -0,0 +1,71 @@
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule ChromicPDF.Connection.Local do
+  @moduledoc false
+
+  use ChromicPDF.Connection
+  alias ChromicPDF.ChromeRunner
+  alias ChromicPDF.Connection.{ConnectionLostError, Tokenizer}
+
+  @type state :: %{
+          port: port(),
+          parent_pid: pid(),
+          tokenizer: Tokenizer.t()
+        }
+
+  @impl ChromicPDF.Connection
+  def handle_init(opts) do
+    port =
+      opts
+      |> Keyword.take([:chrome_args, :discard_stderr, :no_sandbox, :chrome_executable])
+      |> ChromeRunner.port_open()
+
+    Port.monitor(port)
+
+    {:ok, %{port: port, tokenizer: Tokenizer.init()}}
+  end
+
+  @impl ChromicPDF.Connection
+  def handle_msg(msg, %{port: port}) do
+    send(port, {self(), {:command, msg <> "\0"}})
+
+    :ok
+  end
+
+  @impl GenServer
+  def handle_info({_port, {:data, data}}, %{tokenizer: tokenizer} = state) do
+    {msgs, tokenizer} = Tokenizer.tokenize(data, tokenizer)
+
+    for msg <- msgs do
+      send_msg_to_channel(msg, state)
+    end
+
+    {:noreply, %{state | tokenizer: tokenizer}}
+  end
+
+  # Message triggered by Port.monitor/1.
+  # Port is down, likely due to the external process having been killed.
+  def handle_info({:DOWN, _ref, :port, _port, _exit_state}, _state) do
+    raise(ConnectionLostError, """
+    Chrome has stopped or was terminated by an external program.
+
+    If this happened while you were printing a PDF, this may be a problem with Chrome itelf.
+    If this happens at startup and you are running inside a Docker container with a Linux-based
+    image, please see the "Chrome Sandbox in Docker containers" section of the documentation.
+
+    Either way, to see Chrome's error output, configure ChromicPDF with the option
+
+        discard_stderr: false
+    """)
+  end
+
+  @spec port_info(pid()) :: {atom(), term()} | nil
+  def port_info(pid) do
+    GenServer.call(pid, :port_info)
+  end
+
+  @impl GenServer
+  def handle_call(:port_info, _from, %{port: port} = state) do
+    {:reply, Port.info(port), state}
+  end
+end
diff --git a/lib/chromic_pdf/pdf/connection/json_rpc.ex b/lib/chromic_pdf/pdf/json_rpc.ex
similarity index 97%
rename from lib/chromic_pdf/pdf/connection/json_rpc.ex
rename to lib/chromic_pdf/pdf/json_rpc.ex
index 1a4fe16..30135cb 100644
--- a/lib/chromic_pdf/pdf/connection/json_rpc.ex
+++ b/lib/chromic_pdf/pdf/json_rpc.ex
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: Apache-2.0
 
-defmodule ChromicPDF.Connection.JsonRPC do
+defmodule ChromicPDF.JsonRPC do
   @moduledoc false
 
   @type call_id :: integer()
diff --git a/lib/chromic_pdf/pdf/protocol.ex b/lib/chromic_pdf/pdf/protocol.ex
index 1c4591c..2c51e1a 100644
--- a/lib/chromic_pdf/pdf/protocol.ex
+++ b/lib/chromic_pdf/pdf/protocol.ex
@@ -3,7 +3,7 @@
 defmodule ChromicPDF.Protocol do
   @moduledoc false
 
-  alias ChromicPDF.Connection.JsonRPC
+  alias ChromicPDF.JsonRPC
 
   # A protocol is a sequence of JsonRPC calls and responses/notifications.
   #
@@ -13,7 +13,6 @@ defmodule ChromicPDF.Protocol do
   # * Besides, a protocol has a `state` map of arbitrary values.
 
   @type message :: JsonRPC.message()
-  @type dispatch :: (JsonRPC.call() -> JsonRPC.call_id())
 
   @type error :: {:error, term()}
   @type state :: map() | error()
@@ -32,7 +31,7 @@ defmodule ChromicPDF.Protocol do
   #   If no output step exists, the protocol returns `:ok`.
   #   Output step has to be the last step of the protocol.
 
-  @type call_fun :: (state(), dispatch() -> state() | error())
+  @type call_fun :: (state() -> {state(), JsonRPC.call()})
   @type call_step :: {:call, call_fun()}
 
   @type await_fun ::
@@ -44,42 +43,47 @@ defmodule ChromicPDF.Protocol do
 
   @type result :: :ok | {:ok, any()} | error()
 
-  @callback new(keyword()) :: __MODULE__.t()
-  @callback new(JsonRPC.session_id(), keyword()) :: __MODULE__.t()
+  @callback new(keyword()) :: t()
+  @callback new(JsonRPC.session_id(), keyword()) :: t()
 
-  @type t :: %__MODULE__{steps: [step()], state: state()}
+  @type t :: %__MODULE__{
+          steps: [step()],
+          state: state()
+        }
 
   @enforce_keys [:steps, :state]
   defstruct [:steps, :state]
 
-  @spec new([step()], state()) :: __MODULE__.t()
+  @spec new([step()], state()) :: t()
   def new(steps, initial_state \\ %{}) do
     %__MODULE__{steps: steps, state: initial_state}
   end
 
-  # Runs protocol instructions until all done or await instruction reached.
-  @spec run(__MODULE__.t(), dispatch()) :: {:await, __MODULE__.t()} | {:halt, result()}
-  def run(%__MODULE__{state: {:error, error}}, _dispatch) do
-    {:halt, {:error, error}}
+  # Steps a single :call instruction until all done or await instruction reached.
+  @spec step(t(), JsonRPC.call_id()) ::
+          {t(), {:call, JsonRPC.call()} | :await | {:halt, result()}}
+  def step(%__MODULE__{state: {:error, error}} = protocol, _call_id) do
+    {protocol, {:halt, {:error, error}}}
   end
 
-  def run(%__MODULE__{steps: []}, _dispatch), do: {:halt, :ok}
+  def step(%__MODULE__{steps: []} = protocol, _call_id), do: {protocol, {:halt, :ok}}
 
-  def run(%__MODULE__{steps: [{:await, _fun} | _rest]} = protocol, _dispatch),
-    do: {:await, protocol}
+  def step(%__MODULE__{steps: [{:await, _fun} | _rest]} = protocol, _call_id),
+    do: {protocol, :await}
 
-  def run(%__MODULE__{steps: [{:call, fun} | rest], state: state} = protocol, dispatch) do
-    state = fun.(state, dispatch)
-    run(%{protocol | steps: rest, state: state}, dispatch)
+  def step(%__MODULE__{steps: [{:call, fun} | rest], state: state} = protocol, call_id) do
+    {state, call} = fun.(state, call_id)
+
+    {%{protocol | steps: rest, state: state}, {:call, call}}
   end
 
-  def run(%__MODULE__{steps: [{:output, output_fun}], state: state}, _dispatch) do
-    {:halt, {:ok, output_fun.(state)}}
+  def step(%__MODULE__{steps: [{:output, output_fun}], state: state} = protocol, _call_id) do
+    {protocol, {:halt, {:ok, output_fun.(state)}}}
   end
 
   # Returns updated protocol if message could be matched, :no_match otherwise.
-  @spec match_chrome_message(__MODULE__.t(), JsonRPC.message()) ::
-          :no_match | {:match, __MODULE__.t()}
+  @spec match_chrome_message(t(), JsonRPC.message()) ::
+          :no_match | {:match, t()}
   def match_chrome_message(%__MODULE__{steps: steps, state: state} = protocol, msg) do
     {awaits, rest} = Enum.split_while(steps, fn {type, _fun} -> type == :await end)
 
diff --git a/lib/chromic_pdf/pdf/protocol_macros.ex b/lib/chromic_pdf/pdf/protocol_macros.ex
index 286cd14..4450804 100644
--- a/lib/chromic_pdf/pdf/protocol_macros.ex
+++ b/lib/chromic_pdf/pdf/protocol_macros.ex
@@ -4,12 +4,12 @@ defmodule ChromicPDF.ProtocolMacros do
   @moduledoc false
 
   require Logger
-  alias ChromicPDF.Connection.JsonRPC
+  alias ChromicPDF.JsonRPC
 
   # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
   defmacro steps(do: block) do
     quote do
-      alias ChromicPDF.Connection.JsonRPC
+      alias ChromicPDF.JsonRPC
       alias ChromicPDF.Protocol
 
       @behaviour Protocol
@@ -116,7 +116,7 @@ defmodule ChromicPDF.ProtocolMacros do
   defmacro call(name, method, params_from_state, default_params) do
     quote do
       @steps {:call, unquote(name), 2}
-      def unquote(name)(state, dispatch) do
+      def unquote(name)(state, call_id) do
         params =
           fetch_params_for_call(
             state,
@@ -124,13 +124,13 @@ defmodule ChromicPDF.ProtocolMacros do
             unquote(default_params)
           )
 
-        call_id =
+        call =
           case Map.get(state, "sessionId") do
-            nil -> dispatch.({unquote(method), params})
-            session_id -> dispatch.({session_id, unquote(method), params})
+            nil -> {unquote(method), params}
+            session_id -> {session_id, unquote(method), params}
           end
 
-        Map.put(state, :last_call_id, call_id)
+        {Map.put(state, :last_call_id, call_id), call}
       end
     end
   end
diff --git a/mix.exs b/mix.exs
index 32175fa..3e1e9b0 100644
--- a/mix.exs
+++ b/mix.exs
@@ -72,7 +72,6 @@ defmodule ChromicPdf.MixProject do
       {:credo, "~> 1.6", only: [:dev, :test], runtime: false},
       {:ex_doc, ">= 0.0.0", only: [:test, :dev], runtime: false},
       {:junit_formatter, "~> 3.1", only: [:test]},
-      {:mox, "~> 1.0", only: [:test]},
       {:phoenix, "~> 1.5", only: [:dev, :test]},
       {:phoenix_html, "~> 3.0", only: [:dev, :test]},
       {:plug, "~> 1.11", only: [:dev, :test]},
diff --git a/mix.lock b/mix.lock
index d00d1ee..5725645 100644
--- a/mix.lock
+++ b/mix.lock
@@ -15,7 +15,6 @@
   "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
   "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
   "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
-  "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"},
   "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
   "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
   "phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"},


Test patch:
diff --git a/test/integration/connection_lost_test.exs b/test/integration/connection_lost_test.exs
index 475f236..98b10fc 100644
--- a/test/integration/connection_lost_test.exs
+++ b/test/integration/connection_lost_test.exs
@@ -3,14 +3,14 @@
 defmodule ChromicPDF.ConnectionLostTest do
   use ExUnit.Case, async: false
   alias ChromicPDF.Connection
-  alias ChromicPDF.Connection.ConnectionLostError
+  alias ChromicPDF.Connection.{ConnectionLostError, Local}
 
-  describe "when the Chrome process fails at startup or is killed externally" do
+  describe "when the local Chrome process fails at startup or is killed externally" do
     @tag :capture_log
     test "an exception with a nice error message is raised" do
-      {:ok, pid} = Connection.start_link(self(), [])
+      {:ok, pid} = Connection.start_link([])
 
-      port_info = Connection.port_info(pid)
+      port_info = Local.port_info(pid)
 
       Process.unlink(pid)
       Process.monitor(pid)
diff --git a/test/unit/chromic_pdf/pdf/connection_test.exs b/test/unit/chromic_pdf/pdf/connection_test.exs
deleted file mode 100644
index bb58b98..0000000
--- a/test/unit/chromic_pdf/pdf/connection_test.exs
+++ /dev/null
@@ -1,94 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-
-defmodule ChromicPDF.ConnectionTest do
-  use ExUnit.Case, async: true
-  import Mox
-  import ChromicPDF.Connection
-  alias ChromicPDF.ChromeRunnerMock
-
-  @port :some_port
-  @ref :some_ref
-
-  defp new_state do
-    %{
-      chrome_runner: ChromeRunnerMock,
-      port: @port,
-      parent_pid: self(),
-      tokenizer: [],
-      next_call_id: 1
-    }
-  end
-
-  defp new_state(_) do
-    %{state: new_state()}
-  end
-
-  describe "initialization" do
-    test "it spawns Chrome and initializes its state" do
-      opts = [
-        chrome_runner: ChromeRunnerMock,
-        discard_stderr: false,
-        no_sandbox: true,
-        chrome_executable: "/custom/chrome",
-        chrome_args: "--foo"
-      ]
-
-      expected_runner_opts =
-        Keyword.take(opts, [:discard_stderr, :no_sandbox, :chrome_executable, :chrome_args])
-
-      expect(ChromeRunnerMock, :spawn, fn ^expected_runner_opts -> {:ok, @port} end)
-      assert init({self(), opts}) == {:ok, new_state()}
-    end
-  end
-
-  describe "external process supervision" do
-    setup [:new_state]
-
-    test "it suicides when Chrome is terminated externally", %{state: state} do
-      assert handle_info({:EXIT, @port, :normal}, state) ==
-               {:stop, :connection_lost, state}
-
-      assert handle_info({:EXIT, @port, :other_reason}, state) ==
-               {:stop, :connection_lost, state}
-    end
-  end
-
-  describe "graceful shutdown" do
-    setup [:new_state]
-
-    test "it gracefully closes Chrome on shutdown", %{state: %{port: port} = state} do
-      expected_msg = ~s({"id":1,"method":"Browser.close","params":{}})
-      expect(ChromeRunnerMock, :send_msg, fn ^port, ^expected_msg -> :ok end)
-
-      # Inject :DOWN message before calling terminate/2 to avoid locking in receive.
-      send(self(), {:DOWN, @ref, :port, @port, 0})
-
-      assert terminate(:shutdown, state) == :ok
-    end
-  end
-
-  describe "incoming messages" do
-    setup [:new_state]
-
-    test "stores incomplete messages in the tokenizer memo", %{state: state} do
-      assert {:noreply, %{tokenizer: ["foo"]}} = handle_info({@port, {:data, "foo"}}, state)
-    end
-
-    test "decodes complete messages and sends them to parent", %{state: state} do
-      handle_info({@port, {:data, "{}\0"}}, state)
-      assert_receive {:chrome_message, %{}}
-    end
-  end
-
-  describe "outgoing messages" do
-    setup [:new_state]
-
-    test "it encodes messages and sends them to Chrome", %{state: %{port: port} = state} do
-      expected_msg = ~s({"id":1,"method":"method","params":{}})
-      expect(ChromeRunnerMock, :send_msg, fn ^port, ^expected_msg -> :ok end)
-
-      assert {:reply, 1, %{next_call_id: 2}} =
-               handle_call({:dispatch_call, {"method", %{}}}, {self(), @ref}, state)
-    end
-  end
-end
diff --git a/test/unit/support/mox.ex b/test/unit/support/mox.ex
deleted file mode 100644
index bf46f43..0000000
--- a/test/unit/support/mox.ex
+++ /dev/null
@@ -1,3 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-
-Mox.defmock(ChromicPDF.ChromeRunnerMock, for: ChromicPDF.Connection.ChromeRunner)