Powered by AppSignal & Oban Pro

Interactive Browser with Bibbidi

examples/interactive_browser.livemd

Interactive Browser with Bibbidi

Mix.install([
  {:bibbidi, "~> 0.1.0"},
  {:kino, "~> 0.14"}
])

UI Module

defmodule UI do
  @behaviour Kino.Screen
  import Kino.{Control, Input, Screen}

  alias Bibbidi.Commands.BrowsingContext
  alias Bibbidi.Commands.Script

  @viewports %{
    desktop: {1280, 800},
    tablet: {768, 1024},
    phone: {375, 667}
  }

  def new do
    screenshot_frame = Kino.Frame.new()
    log_frame = Kino.Frame.new()

    state = %{
      browser: nil,
      conn: nil,
      context: nil,
      log_listener: nil,
      screenshot_frame: screenshot_frame,
      log_frame: log_frame,
      elements: [],
      page_url: nil,
      page_title: nil,
      viewport: :desktop,
      js_result: nil,
      status: nil
    }

    Kino.Layout.grid(
      [
        Kino.Screen.new(__MODULE__, state),
        Kino.Markdown.new("---\n### Screenshots"),
        screenshot_frame,
        Kino.Markdown.new("---\n### Console Log"),
        log_frame
      ],
      gap: 8
    )
  end

  # -- Render ------------------------------------------------------------------

  @impl true
  def render(%{conn: nil} = state) do
    start_btn = button("Start Browser") |> control(&handle_start/2)

    [
      state.status && Kino.Markdown.new(state.status),
      start_btn
    ]
    |> Enum.reject(&is_nil/1)
    |> Kino.Layout.grid(gap: 16)
  end

  def render(state) do
    page_info =
      if state.page_url do
        Kino.Markdown.new("**#{state.page_title || "Untitled"}** | `#{state.page_url}`")
      end

    status = state.status && Kino.Markdown.new(state.status)

    # Navigation
    back_btn = button("Back") |> control(&handle_back/2)
    fwd_btn = button("Forward") |> control(&handle_forward/2)
    reload_btn = button("Reload") |> control(&handle_reload/2)

    nav_form =
      form([url: text("URL", default: state.page_url || "https://example.com")], submit: "Go")
      |> control(&handle_navigate/2)

    # Actions
    click_form =
      form([click_text: text("Button/Link text")], submit: "Click")
      |> control(&handle_click/2)

    viewport_form =
      form(
        [
          size:
            select("Viewport",
              [desktop: "Desktop (1280x800)", tablet: "Tablet (768x1024)", phone: "Phone (375x667)"],
              default: state.viewport
            )
        ],
        report_changes: true
      )
      |> control(&handle_viewport/2)

    # JS Console
    js_form =
      form([expression: textarea("JavaScript", default: "document.title")], submit: "Run")
      |> control(&handle_js/2)

    js_output = state.js_result && Kino.Markdown.new(state.js_result)

    # Tools
    rescan_btn = button("Rescan Page") |> control(&handle_rescan/2)
    screenshot_btn = button("Screenshot") |> control(&handle_screenshot/2)
    stop_btn = button("Stop Browser") |> control(&handle_stop/2)

    # Elements
    elements_table =
      if state.elements != [] do
        Kino.DataTable.new(state.elements,
          keys: [:tag, :id, :text],
          name: "Buttons & Links"
        )
      end

    [
      page_info,
      status,
      Kino.Layout.grid([back_btn, fwd_btn, reload_btn], columns: 3),
      nav_form,
      Kino.Layout.grid([click_form, viewport_form], columns: 2),
      js_form,
      js_output,
      Kino.Layout.grid([rescan_btn, screenshot_btn, stop_btn], columns: 3),
      elements_table
    ]
    |> Enum.reject(&is_nil/1)
    |> Kino.Layout.grid(gap: 8)
  end

  # -- Start / Stop ------------------------------------------------------------

  defp handle_start(_, state) do
    {:ok, browser} = Bibbidi.Browser.start_link(headless: false)
    {:ok, conn} = Bibbidi.Connection.start_link(browser: browser)
    {:ok, _caps} = Bibbidi.Session.new(conn)
    {:ok, %{"contexts" => [%{"context" => context} | _]}} = BrowsingContext.get_tree(conn)

    BrowsingContext.set_viewport(conn, context, %{width: 1280, height: 800})
    install_observers(conn)
    log_listener = start_log_listener(conn, state.log_frame)

    %{state |
      browser: browser,
      conn: conn,
      context: context,
      log_listener: log_listener,
      elements: [],
      page_url: nil,
      page_title: nil,
      viewport: :desktop,
      js_result: nil,
      status: "Browser started — navigate to a page to get started."
    }
  rescue
    e -> %{state | status: "**Error starting browser:** `#{Exception.message(e)}`"}
  end

  defp handle_stop(_, state) do
    if state.log_listener, do: Process.exit(state.log_listener, :kill)
    Bibbidi.Connection.close(state.conn)
    Bibbidi.Browser.stop(state.browser)
    Kino.Frame.clear(state.screenshot_frame)
    Kino.Frame.clear(state.log_frame)

    %{state |
      browser: nil,
      conn: nil,
      context: nil,
      log_listener: nil,
      elements: [],
      page_url: nil,
      page_title: nil,
      js_result: nil,
      status: "Browser stopped."
    }
  end

  # -- Navigation --------------------------------------------------------------

  defp handle_navigate(%{data: %{url: url}}, state) do
    case BrowsingContext.navigate(state.conn, state.context, url, wait: "complete") do
      {:ok, _} ->
        refresh_page(state, "Navigated to `#{url}`")

      {:error, err} ->
        %{state | status: "**Error:** `#{inspect(err)}`"}
    end
  end

  defp handle_back(_, state) do
    case BrowsingContext.traverse_history(state.conn, state.context, -1) do
      {:ok, _} ->
        Process.sleep(500)
        refresh_page(state, "Went back")

      {:error, _} ->
        %{state | status: "No previous page in history"}
    end
  end

  defp handle_forward(_, state) do
    case BrowsingContext.traverse_history(state.conn, state.context, 1) do
      {:ok, _} ->
        Process.sleep(500)
        refresh_page(state, "Went forward")

      {:error, _} ->
        %{state | status: "No next page in history"}
    end
  end

  defp handle_reload(_, state) do
    case BrowsingContext.reload(state.conn, state.context, wait: "complete") do
      {:ok, _} -> refresh_page(state, "Page reloaded")
      {:error, err} -> %{state | status: "**Error:** `#{inspect(err)}`"}
    end
  end

  # -- Actions -----------------------------------------------------------------

  defp handle_click(%{data: %{click_text: click_text}}, state) do
    js = """
    (() => {
      const els = [...document.querySelectorAll('button, a, [role="button"]')];
      const el = els.find(e => e.textContent.trim().includes(#{JSON.encode!(click_text)}));
      if (el) { el.click(); return true; }
      return false;
    })()
    """

    case Script.evaluate(state.conn, js, %{context: state.context}, user_activation: true) do
      {:ok, %{"result" => %{"value" => true}}} ->
        Process.sleep(1000)
        refresh_page(state, "Clicked **\"#{click_text}\"**")

      {:ok, %{"result" => %{"value" => false}}} ->
        %{state | status: "No element found matching **\"#{click_text}\"**"}

      {:error, err} ->
        %{state | status: "**Error:** `#{inspect(err)}`"}
    end
  end

  defp handle_viewport(%{data: %{size: size}}, state) do
    {w, h} = Map.fetch!(@viewports, size)

    case BrowsingContext.set_viewport(state.conn, state.context, %{width: w, height: h}) do
      {:ok, _} -> %{state | viewport: size, status: "Viewport set to #{w}x#{h}"}
      {:error, err} -> %{state | status: "**Error:** `#{inspect(err)}`"}
    end
  end

  defp handle_js(%{data: %{expression: expression}}, state) do
    case Script.evaluate(state.conn, expression, %{context: state.context}) do
      {:ok, %{"result" => result}} ->
        %{state | js_result: "`-> #{format_js_result(result)}`"}

      {:ok, %{"exceptionDetails" => %{"text" => text}}} ->
        %{state | js_result: "**Exception:** `#{text}`"}

      {:error, err} ->
        %{state | js_result: "**Error:** `#{inspect(err)}`"}
    end
  end

  defp handle_rescan(_, state) do
    refresh_page(state, "Page rescanned")
  end

  defp handle_screenshot(_, state) do
    case BrowsingContext.capture_screenshot(state.conn, state.context) do
      {:ok, %{"data" => base64_data}} ->
        image = Kino.Image.new(Base.decode64!(base64_data), :png)
        Kino.Frame.append(state.screenshot_frame, image)
        %{state | status: "Screenshot captured"}

      {:error, err} ->
        %{state | status: "**Error:** `#{inspect(err)}`"}
    end
  end

  # -- Helpers -----------------------------------------------------------------

  defp refresh_page(state, status) do
    {url, title} = get_page_info(state.conn, state.context)
    elements = list_elements(state.conn, state.context)
    %{state | page_url: url, page_title: title, elements: elements, status: status}
  end

  defp get_page_info(conn, context) do
    js = "JSON.stringify({url: location.href, title: document.title})"

    case Script.evaluate(conn, js, %{context: context}) do
      {:ok, %{"result" => %{"type" => "string", "value" => json}}} ->
        info = JSON.decode!(json)
        {info["url"], info["title"]}

      _ ->
        {nil, nil}
    end
  end

  defp list_elements(conn, context) do
    js = """
    JSON.stringify(
      [...document.querySelectorAll('button, a, [role="button"]')].map(el => ({
        tag: el.tagName.toLowerCase(),
        id: el.id || "",
        text: (el.textContent || "").trim().substring(0, 20)
      }))
    )
    """

    case Script.evaluate(conn, js, %{context: context}) do
      {:ok, %{"result" => %{"type" => "string", "value" => json}}} ->
        json
        |> JSON.decode!()
        |> Enum.map(fn el ->
          %{tag: el["tag"], id: el["id"], text: el["text"]}
        end)

      _ ->
        []
    end
  end

  defp format_js_result(%{"type" => "string", "value" => v}), do: ~s("#{v}")
  defp format_js_result(%{"type" => "number", "value" => v}), do: to_string(v)
  defp format_js_result(%{"type" => "boolean", "value" => v}), do: to_string(v)
  defp format_js_result(%{"type" => "null"}), do: "null"
  defp format_js_result(%{"type" => "undefined"}), do: "undefined"
  defp format_js_result(%{"type" => type} = r), do: "#{type}: #{inspect(Map.get(r, "value", ""))}"

  # -- Observers ---------------------------------------------------------------

  defp install_observers(conn) do
    Script.add_preload_script(conn, """
    () => {
      document.addEventListener('click', (e) => {
        const tag = e.target.tagName.toLowerCase();
        const text = (e.target.textContent || '').trim().substring(0, 40);
        console.log('[click] <' + tag + '> ' + text);
      }, true);

      const attach = () => {
        new MutationObserver((mutations) => {
          const added = mutations.reduce((n, m) => n + m.addedNodes.length, 0);
          const removed = mutations.reduce((n, m) => n + m.removedNodes.length, 0);
          if (added || removed) console.log('[dom] +' + added + ' -' + removed + ' nodes');
        }).observe(document.documentElement, { childList: true, subtree: true });
      };

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', attach);
      } else {
        attach();
      }
    }
    """)
  end

  # -- Console log listener ----------------------------------------------------

  @log_events [
    "log.entryAdded",
    "browsingContext.navigationStarted",
    "browsingContext.load",
    "browsingContext.domContentLoaded",
    "browsingContext.fragmentNavigated",
    "browsingContext.userPromptOpened",
    "browsingContext.userPromptClosed"
  ]

  defp start_log_listener(conn, log_frame) do
    Bibbidi.Session.subscribe(conn, @log_events)

    spawn(fn ->
      for event <- @log_events, do: Bibbidi.Connection.subscribe(conn, event)
      log_loop(log_frame)
    end)
  end

  defp log_loop(frame) do
    receive do
      {:bibbidi_event, "log.entryAdded", params} ->
        level = params["level"] || "info"
        text = params["text"] || ""
        Kino.Frame.append(frame, Kino.Markdown.new("`[#{level}]` #{text}"))

      {:bibbidi_event, "browsingContext.navigationStarted", params} ->
        Kino.Frame.append(frame, Kino.Markdown.new("`[nav]` started: #{params["url"]}"))

      {:bibbidi_event, "browsingContext.domContentLoaded", params} ->
        Kino.Frame.append(frame, Kino.Markdown.new("`[nav]` DOM ready: #{params["url"]}"))

      {:bibbidi_event, "browsingContext.load", params} ->
        Kino.Frame.append(frame, Kino.Markdown.new("`[nav]` loaded: #{params["url"]}"))

      {:bibbidi_event, "browsingContext.fragmentNavigated", params} ->
        Kino.Frame.append(frame, Kino.Markdown.new("`[nav]` fragment: #{params["url"]}"))

      {:bibbidi_event, "browsingContext.userPromptOpened", params} ->
        type = params["type"] || "dialog"
        msg = params["message"] || ""
        Kino.Frame.append(frame, Kino.Markdown.new("`[prompt]` #{type} opened: #{msg}"))

      {:bibbidi_event, "browsingContext.userPromptClosed", params} ->
        accepted = if params["accepted"], do: "accepted", else: "dismissed"
        Kino.Frame.append(frame, Kino.Markdown.new("`[prompt]` #{accepted}"))
    end

    log_loop(frame)
  end
end

Controls

UI.new()