From e4143f1a2073af94d3b8029ddb82606b15b103a5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 14 Jul 2026 15:20:46 +0100 Subject: [PATCH 01/43] make verification work in a banner --- assets/js/dashboard/index.tsx | 6 +- .../dashboard/stats/graph/visitor-graph.tsx | 2 +- assets/js/dashboard/verification/portal.tsx | 9 + extra/lib/plausible_web/live/verification.ex | 128 ++++----- .../controllers/stats_controller.ex | 35 ++- lib/plausible_web/live/awaiting_pageviews.ex | 99 ------- .../live/components/verification.ex | 257 +++++++++--------- lib/plausible_web/live/installation.ex | 23 +- lib/plausible_web/live/sites.ex | 25 +- lib/plausible_web/router.ex | 9 - .../templates/stats/stats.html.heex | 6 + .../controllers/stats_controller_test.exs | 59 ++-- .../live/components/verification_test.exs | 14 +- test/plausible_web/live/installation_test.exs | 56 ++-- test/plausible_web/live/verification_test.exs | 100 +++---- 15 files changed, 395 insertions(+), 433 deletions(-) create mode 100644 assets/js/dashboard/verification/portal.tsx delete mode 100644 lib/plausible_web/live/awaiting_pageviews.ex diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index 1736f963d227..b4c8e0c1cb15 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -11,6 +11,7 @@ import { isRealTimeDashboard } from './util/filters' import { GraphIntervalProvider } from './stats/graph/graph-interval-context' import { ImportsIncludedProvider } from './stats/graph/imports-included-context' import { CurrentVisitorsProvider } from './current-visitors-context' +import { VerificationLiveViewPortal } from './verification/portal' function DashboardStats({ importedDataInView, @@ -21,7 +22,10 @@ function DashboardStats({ }) { return ( <> - +
+ + +
diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index e16a9d955eef..1483c712ad7e 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -113,7 +113,7 @@ export default function VisitorGraph({ !showFullLoader return ( -
+
<>
{ + return
+ + }, + () => true +) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 0fc148d3a2b0..21e4179aeb2a 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -1,8 +1,7 @@ defmodule PlausibleWeb.Live.Verification do @moduledoc """ - LiveView coordinating the site verification process. - Onboarding new sites, renders a standalone component. - Embedded modal variant is available for general site settings. + LiveView coordinating the site verification process. Rendered as a banner + on top of the React dashboard. """ use PlausibleWeb, :live_view @@ -12,10 +11,11 @@ defmodule PlausibleWeb.Live.Verification do @component PlausibleWeb.Live.Components.Verification @slowdown_for_frequent_checking :timer.seconds(5) + @use_portal? Mix.env() not in [:test, :ce_test] def mount( - %{"domain" => domain} = params, - _session, + _params, + %{"domain" => domain} = session, socket ) do current_user = socket.assigns.current_user @@ -38,8 +38,6 @@ defmodule PlausibleWeb.Live.Verification do super_admin? = Plausible.Auth.super_admin?(current_user) has_pageviews? = has_pageviews?(site) - custom_url_input? = params["custom_url"] == "true" - socket = assign(socket, url_to_verify: nil, @@ -48,18 +46,18 @@ defmodule PlausibleWeb.Live.Verification do domain: domain, has_pageviews?: has_pageviews?, component: @component, - installation_type: get_installation_type(params, site), + installation_type: get_installation_type(session["installation_type"], site), report_to: self(), delay: private[:delay] || 500, slowdown: private[:slowdown] || 500, - flow: params["flow"] || "", + flow: session["flow"] || "", checks_pid: nil, attempts: 0, polling_pageviews?: false, - custom_url_input?: custom_url_input? + custom_url_input?: false ) - if connected?(socket) and not custom_url_input? do + if connected?(socket) do launch_delayed(socket) end @@ -67,8 +65,23 @@ defmodule PlausibleWeb.Live.Verification do end def render(assigns) do + assigns = assign(assigns, :use_portal?, @use_portal?) + + ~H""" +
+ <%= if @use_portal? do %> + <.portal id="verification-portal-source" target="#verification-portal-target"> + <.verification_content {assigns} /> + + <% else %> + <.verification_content {assigns} /> + <% end %> +
+ """ + end + + defp verification_content(assigns) do ~H""" - <.custom_url_form :if={@custom_url_input?} domain={@domain} /> <.live_component :if={not @custom_url_input?} @@ -94,6 +107,10 @@ defmodule PlausibleWeb.Live.Verification do {:noreply, reset_component(socket)} end + def handle_event("show-custom-url-form", _, socket) do + {:noreply, assign(socket, custom_url_input?: true)} + end + def handle_event("verify-custom-url", %{"custom_url" => custom_url}, socket) do socket = socket @@ -166,24 +183,24 @@ defmodule PlausibleWeb.Live.Verification do end def handle_info(:check_pageviews, socket) do - socket = - if has_pageviews?(socket.assigns.site) do - redirect_to_stats(socket) - else + if has_pageviews?(socket.assigns.site) do + {:noreply, assign(socket, has_pageviews?: true, polling_pageviews?: false)} + else + socket = socket |> assign(polling_pageviews?: false) |> schedule_pageviews_check() - end - {:noreply, socket} + {:noreply, socket} + end end @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types() |> Enum.map(&String.to_atom/1) - defp get_installation_type(params, site) do + defp get_installation_type(installation_type, site) do cond do - params["installation_type"] in PlausibleWeb.Tracker.supported_installation_types() -> - params["installation_type"] + installation_type in PlausibleWeb.Tracker.supported_installation_types() -> + installation_type (saved_installation_type = get_saved_installation_type(site)) in @supported_installation_types_atoms -> Atom.to_string(saved_installation_type) @@ -212,11 +229,6 @@ defmodule PlausibleWeb.Live.Verification do end end - defp redirect_to_stats(socket) do - stats_url = Routes.stats_path(PlausibleWeb.Endpoint, :stats, socket.assigns.domain, []) - redirect(socket, to: stats_url) - end - defp reset_component(socket) do update_component(socket, message: "We're visiting your site to ensure that everything is working", @@ -245,44 +257,32 @@ defmodule PlausibleWeb.Live.Verification do defp custom_url_form(assigns) do ~H""" - <.focus_box> -
- -
-
-

- Enter Your Custom URL -

-

- Please enter the URL where your website with the Plausible script is located. -

-
-
- - -
- -
-
- + <.notice title="Enter your custom URL" theme={:gray} class="mb-4"> + <:icon> + + +

+ Please enter the URL where your website with the Plausible script is located. +

+
+ + + +
+ """ end end diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 38e9169f66f9..f96cd2f94855 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -70,9 +70,6 @@ defmodule PlausibleWeb.StatsController do team_identifier = site.team.identifier - skip_to_dashboard? = - conn.params["skip_to_dashboard"] == "true" or consolidated_view? - {:ok, segments} = Plausible.Segments.get_all_for_site(site, site_role) segments = Enum.map(segments, &Plausible.Segments.to_response_map(&1, site)) @@ -80,9 +77,19 @@ defmodule PlausibleWeb.StatsController do consolidated_view? and not consolidated_view_available? and site_role != :super_admin -> redirect(conn, to: Routes.site_path(conn, :index)) - (stats_start_date && can_see_stats?) || (can_see_stats? && skip_to_dashboard?) -> + not can_see_stats? -> + site = Plausible.Repo.preload(site, :owners) + render(conn, "site_locked.html", site: site, dogfood_page_path: dogfood_page_path) + + true -> flags = get_flags(current_user, site) + verify_installation? = + ee?() and + not is_nil(current_user) and + not consolidated_view? and + conn.params["verify_installation"] == "true" + conn |> put_resp_header("x-robots-tag", "noindex, nofollow") |> render("stats.html", @@ -106,15 +113,15 @@ defmodule PlausibleWeb.StatsController do exploration_journey_end_event: exploration_journey_end_event, exploration_max_journey_steps: exploration_max_journey_steps, team_identifier: team_identifier, - limited_to_segment_id: nil + limited_to_segment_id: nil, + connect_live_socket: verify_installation?, + verify_installation?: verify_installation?, + verification_session: %{ + "domain" => site.domain, + "flow" => conn.params["flow"], + "installation_type" => conn.params["installation_type"] + } ) - - !stats_start_date && can_see_stats? -> - redirect(conn, to: Routes.site_path(conn, :verification, site.domain)) - - Teams.locked?(site.team) -> - site = Plausible.Repo.preload(site, :owners) - render(conn, "site_locked.html", site: site, dogfood_page_path: dogfood_page_path) end end @@ -422,7 +429,9 @@ defmodule PlausibleWeb.StatsController do exploration_journey_end_event: exploration_journey_end_event, exploration_max_journey_steps: exploration_max_journey_steps, team_identifier: team_identifier, - limited_to_segment_id: limited_to_segment_id + limited_to_segment_id: limited_to_segment_id, + verify_installation?: false, + verification_session: %{} ) end end diff --git a/lib/plausible_web/live/awaiting_pageviews.ex b/lib/plausible_web/live/awaiting_pageviews.ex deleted file mode 100644 index 29c4f3e731d4..000000000000 --- a/lib/plausible_web/live/awaiting_pageviews.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule PlausibleWeb.Live.AwaitingPageviews do - @moduledoc """ - A replacement for installation verification on Community Edition. - """ - use PlausibleWeb, :live_view - - import PlausibleWeb.Components.Generic - - def mount( - %{"domain" => domain} = params, - _session, - socket - ) do - current_user = socket.assigns.current_user - - site = - Plausible.Sites.get_for_user!(current_user, domain, - roles: [ - :owner, - :admin, - :editor, - :super_admin, - :viewer - ] - ) - - private = Map.get(socket.private.connect_info, :private, %{}) - - has_pageviews? = has_pageviews?(site) - - socket = - assign(socket, - site: site, - domain: domain, - has_pageviews?: has_pageviews?, - delay: private[:delay] || 500, - flow: params["flow"] || "", - polling_pageviews?: false - ) - - socket = - if has_pageviews? do - redirect_to_stats(socket) - else - schedule_pageviews_check(socket) - end - - {:ok, socket} - end - - def render(assigns) do - ~H""" - - <.awaiting_pageviews /> - """ - end - - defp awaiting_pageviews(assigns) do - ~H""" - <.focus_box> -
-
-

Awaiting your first pageview …

-
- - """ - end - - def handle_info(:check_pageviews, socket) do - socket = - if has_pageviews?(socket.assigns.site) do - redirect_to_stats(socket) - else - socket - |> assign(polling_pageviews?: false) - |> schedule_pageviews_check() - end - - {:noreply, socket} - end - - defp schedule_pageviews_check(socket) do - if socket.assigns.polling_pageviews? do - socket - else - Process.send_after(self(), :check_pageviews, socket.assigns.delay * 2) - assign(socket, polling_pageviews?: true) - end - end - - defp redirect_to_stats(socket) do - stats_url = Routes.stats_path(PlausibleWeb.Endpoint, :stats, socket.assigns.domain, []) - redirect(socket, to: stats_url) - end - - defp has_pageviews?(site) do - Plausible.Stats.Clickhouse.has_pageviews?(site) - end -end diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index ef8113d0a0a8..a4fd779d3880 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -1,7 +1,7 @@ defmodule PlausibleWeb.Live.Components.Verification do @moduledoc """ This component is responsible for rendering the verification progress - and diagnostics. + and diagnostics as a compact banner on top of the dashboard. """ use Phoenix.LiveComponent use Plausible @@ -11,6 +11,17 @@ defmodule PlausibleWeb.Live.Components.Verification do import PlausibleWeb.Components.Generic + @container_id "verification-ui" + # Dismissing hides the banner immediately and strips `verify_installation` + # from the URL (the same param that got it rendered in the first place - + # see PlausibleWeb.StatsController), so a refresh doesn't bring it back. + @dismiss_onclick "document.getElementById('#{@container_id}').classList.add('hidden');" <> + "var u = new window.URL(window.location.href);" <> + "u.searchParams.delete('verify_installation');" <> + "u.searchParams.delete('installation_type');" <> + "u.searchParams.delete('flow');" <> + "window.history.replaceState(null, '', u);" + attr(:domain, :string, required: true) attr(:message, :string, @@ -28,13 +39,28 @@ defmodule PlausibleWeb.Live.Components.Verification do attr(:awaiting_first_pageview?, :boolean, default: false) def render(assigns) do + assigns = + assigns + |> assign(:dismiss_onclick, @dismiss_onclick) + |> assign(:container_id, @container_id) + ~H""" -
+
+ <.render_progress :if={not @finished?} message={@message} /> <.render_success :if={@finished? and @success?} awaiting_first_pageview?={@awaiting_first_pageview?} domain={@domain} + super_admin?={@super_admin?} + verification_state={@verification_state} /> <.render_failed :if={@finished? and not @success?} @@ -43,9 +69,7 @@ defmodule PlausibleWeb.Live.Components.Verification do domain={@domain} flow={@flow} installation_type={@installation_type} - /> - <.render_super_admin_diagnostics - :if={not is_nil(@verification_state) && @super_admin? && @finished?} + super_admin?={@super_admin?} verification_state={@verification_state} />
@@ -54,149 +78,122 @@ defmodule PlausibleWeb.Live.Components.Verification do defp render_progress(assigns) do ~H""" - <.focus_box> -
-
-
-
- <.title>Verifying your installation -

{@message}

-
- + <.notice title="Verifying your installation" theme={:gray}> + <:icon> +
+ +

{@message}

+ """ end defp render_success(assigns) do ~H""" - <.focus_box> -
- -
- -
- <.title>Success! -

- Your installation is working and visitors are being counted accurately. - - Awaiting your first pageview... - -

-
- <.button_link - href={"/#{URI.encode_www_form(@domain)}?skip_to_dashboard=true"} - class="w-full font-bold mb-4" - > - Go to the dashboard - - + <.notice title="Success!" theme={:gray} icon_class="text-green-600 dark:text-green-500"> + <:icon> + + + Your installation is working and visitors are being counted accurately. + + Awaiting your first pageview... + + <.super_admin_diagnostics + :if={@super_admin? and not is_nil(@verification_state)} + verification_state={@verification_state} + /> + """ end defp render_failed(assigns) do ~H""" - <.focus_box> -
- -
- -
- <.title>{List.first(@interpretation.errors)} -

- {List.first(@interpretation.recommendations).text}.  - <.styled_link href={List.first(@interpretation.recommendations).url} new_tab={true}> - Learn more - -

-
- -
- <.button_link mt?={false} href="#" phx-click="retry" class="w-full"> + <.notice + title={ + if @interpretation, + do: List.first(@interpretation.errors), + else: "We couldn't verify your installation" + } + theme={:red} + > + <:icon> + + + <:actions> + <.button_link mt?={false} href="#" phx-click="retry" size="sm"> Verify installation again -
- <:footer> - <.focus_list> - <:item :if={ - @interpretation && is_map(@interpretation.data) && - @interpretation.data[:offer_custom_url_input] + +

+ {List.first(@interpretation.recommendations).text}.  + <.styled_link href={List.first(@interpretation.recommendations).url} new_tab={true}> + Learn more + +

+

+ + Is your website located at a different URL? + <.styled_link href="#" phx-click="show-custom-url-form" id="verify-custom-url-link"> + Click here + + + = 3}> + Need further help with your installation? + <.styled_link href="https://plausible.io/contact"> + Contact us + + + + Need to see installation instructions again? + <.styled_link href={ + Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, + flow: @flow, + installation_type: @installation_type + ) }> - - Is your website located at a different URL? - <.styled_link href={ - Routes.site_path(PlausibleWeb.Endpoint, :verification, @domain, - flow: @flow, - installation_type: @installation_type, - custom_url: true - ) - }> - Click here - - - - <:item :if={ee?() and @attempts >= 3}> - Need further help with your installation? - <.styled_link href="https://plausible.io/contact"> - Contact us - - - <:item> - Need to see installation instructions again? - <.styled_link href={ - Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, - flow: @flow, - installation_type: @installation_type - ) - }> - Click here - - - <:item> - Run verification later and go to site settings? - <.styled_link href={"/#{URI.encode_www_form(@domain)}/settings/general"}> - Click here - - - - - + Click here + + +

+ <.super_admin_diagnostics + :if={@super_admin? and not is_nil(@verification_state)} + verification_state={@verification_state} + /> + """ end - defp render_super_admin_diagnostics(assigns) do + defp super_admin_diagnostics(assigns) do ~H""" - <.focus_box> -
-

- - As a super-admin, you're eligible to see diagnostics details. Click to expand. - -

-
- <.focus_list> - <:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}> - - {Phoenix.Naming.humanize(diag)}: - {to_string_value(value)} - - - -
+
+

+ + As a super-admin, you're eligible to see diagnostics details. Click to expand. + +

+
+ <.focus_list> + <:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}> + + {Phoenix.Naming.humanize(diag)}: {to_string_value(value)} + + +
- +
""" end diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index 88a16ae2e742..b34c6f11ae22 100644 --- a/lib/plausible_web/live/installation.ex +++ b/lib/plausible_web/live/installation.ex @@ -328,14 +328,21 @@ defmodule PlausibleWeb.Live.Installation do :installation ) - {:noreply, - push_navigate(socket, - to: - Routes.site_path(socket, :verification, socket.assigns.site.domain, - flow: socket.assigns.flow, - installation_type: config.installation_type - ) - )} + domain = socket.assigns.site.domain + + destination = + on_ee do + Routes.stats_path(socket, :stats, domain, + verify_installation: true, + flow: socket.assigns.flow, + installation_type: config.installation_type + ) + else + _need_to_use_variable_on_ce = config + Routes.stats_path(socket, :stats, domain, []) + end + + {:noreply, push_navigate(socket, to: destination)} end defp initialize_installation_data(flow, site, params) do diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index 11217bd370a1..911835aacb1a 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -533,6 +533,13 @@ defmodule PlausibleWeb.Live.Sites do attr(:sparkline, :map, required: true) def site(assigns) do + assigns = + assign( + assigns, + :needs_verification?, + ee?() and is_nil(Plausible.Sites.stats_start_date(assigns.site)) + ) + ~H"""
  • <.unstyled_link - href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, @site.domain, [])} + href={ + Routes.stats_path( + PlausibleWeb.Endpoint, + :stats, + @site.domain, + if(@needs_verification?, + do: [verify_installation: true, flow: PlausibleWeb.Flows.provisioning()], + else: [] + ) + ) + } class="block group-has-[.phx-click-loading]/sort:animate-pulse group-has-[.phx-click-loading]/sort:pointer-events-none" >
    @@ -565,6 +582,12 @@ defmodule PlausibleWeb.Live.Sites do > {@site.domain} + + Setup pending +
  • <.site_stats sparkline={@sparkline} /> diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 6c1439f81f25..b88211a93809 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -602,15 +602,6 @@ defmodule PlausibleWeb.Router do live "/:domain/installation", Installation, :installation, as: :site end - scope assigns: %{ - dogfood_page_path: "/:website/verification" - } do - live "/:domain/verification", - on_ee(do: Verification, else: AwaitingPageviews), - :verification, - as: :site - end - scope assigns: %{ dogfood_page_path: "/:website/change-domain" } do diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index 6a8058bb7d73..aceb45a07ff2 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -57,6 +57,12 @@ data-limited-to-segment-id={Jason.encode!(@limited_to_segment_id)} >
    + <%= if @verify_installation? do %> + {live_render(@conn, PlausibleWeb.Live.Verification, + id: "live-verification", + session: @verification_session + )} + <% end %> <%= if ee?() && !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
    diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 38155954a91d..fab0eef0ba75 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -104,28 +104,28 @@ defmodule PlausibleWeb.StatsControllerTest do assert resp =~ "Getting started" end - test "public site - redirect to /login when no stats because verification requires it", %{ - conn: conn - } do + test "public site - shows an empty dashboard without stats (no verification banner)", + %{ + conn: conn + } do new_site(domain: "some-other-public-site.io", public: true) - conn = get(conn, conn |> get("/some-other-public-site.io") |> redirected_to()) + resp = get(conn, "/some-other-public-site.io") |> html_response(200) - assert redirected_to(conn) == - Routes.auth_path(conn, :login_form, - return_to: "/some-other-public-site.io/verification" - ) + refute resp =~ "Verifying your installation" end - test "public site - no stats with skip_to_dashboard", %{ - conn: conn - } do + test "public site - anonymous visitors never see the verification banner, even with the param", + %{ + conn: conn + } do new_site(domain: "some-other-public-site.io", public: true) - conn = get(conn, "/some-other-public-site.io?skip_to_dashboard=true") - resp = html_response(conn, 200) + resp = + get(conn, "/some-other-public-site.io?verify_installation=true") |> html_response(200) assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + refute resp =~ "Verifying your installation" end test "can not view stats of a private website", %{conn: conn} do @@ -147,15 +147,18 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" end - test "can view stats of a website I've created, enforcing pageviews check skip", %{ - conn: conn, - site: site - } do - resp = conn |> get(conn |> get("/" <> site.domain) |> redirected_to()) |> html_response(200) - refute text_of_attr(resp, @react_container, "data-logged-in") == "true" + test "can view stats of a website I've created; verification banner only shows with the explicit param", + %{ + conn: conn, + site: site + } do + resp = get(conn, "/" <> site.domain) |> html_response(200) + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + refute resp =~ "Verifying your installation" - resp = conn |> get("/" <> site.domain <> "?skip_to_dashboard=true") |> html_response(200) + resp = conn |> get("/" <> site.domain <> "?verify_installation=true") |> html_response(200) assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + assert resp =~ "Verifying your installation" end on_ee do @@ -334,9 +337,9 @@ defmodule PlausibleWeb.StatsControllerTest do end test "does not show CRM link to the site", %{conn: conn, site: site} do - conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) + resp = get(conn, "/" <> site.domain) |> html_response(200) - refute html_response(conn, 200) =~ "/cs/sites" + refute resp =~ "/cs/sites" end test "all segments (personal or site) are stuffed into dataset, with their associated owner_id and owner_name", @@ -390,8 +393,8 @@ defmodule PlausibleWeb.StatsControllerTest do test "can enter verification when site is without stats", %{conn: conn} do site = new_site() - conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) - assert html_response(conn, 200) =~ "Verifying your installation" + resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + assert resp =~ "Verifying your installation" end test "can view a private locked dashboard with stats", %{conn: conn} do @@ -410,8 +413,8 @@ defmodule PlausibleWeb.StatsControllerTest do site = new_site(owner: user) site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() - conn = get(conn, conn |> get("/#{site.domain}") |> redirected_to()) - assert html_response(conn, 200) =~ "Verifying your installation" + resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + assert resp =~ "Verifying your installation" end test "can view a locked public dashboard", %{conn: conn} do @@ -427,9 +430,9 @@ defmodule PlausibleWeb.StatsControllerTest do on_ee do test "shows CRM link to the site", %{conn: conn} do site = new_site() - conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) + resp = get(conn, "/" <> site.domain) |> html_response(200) - assert html_response(conn, 200) =~ + assert resp =~ Routes.customer_support_site_path(PlausibleWeb.Endpoint, :show, site.id) end end diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs index 2645de32cc4e..bf3d6a1a479d 100644 --- a/test/plausible_web/live/components/verification_test.exs +++ b/test/plausible_web/live/components/verification_test.exs @@ -112,7 +112,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) end - test "renders link to verify installation at a different URL" do + test "renders a click-to-show-form link to verify installation at a different URL" do interpretation = Verification.Checks.interpret_diagnostics(%State{ url: "example.com", @@ -125,9 +125,6 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do assert interpretation.data.offer_custom_url_input == true - expected_link_href = - PlausibleWeb.Router.Helpers.site_path(PlausibleWeb.Endpoint, :verification, "example.com") - html = render_component(@component, domain: "example.com", @@ -136,12 +133,11 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do interpretation: interpretation ) - assert text_of_element(html, "#verify-custom-url-link") =~ "different URL?" - assert text_of_attr(html, "#verify-custom-url-link a", "href") =~ expected_link_href - assert text_of_attr(html, "#verify-custom-url-link a", "href") =~ "custom_url=true" + assert text_of_element(html, "#verify-custom-url-link") =~ "Click here" + assert element_exists?(html, ~s|a#verify-custom-url-link[phx-click="show-custom-url-form"]|) end - test "offers escape paths: settings and installation instructions on failure" do + test "offers an installation-instructions escape path on failure, no more settings link" do html = render_component(@component, domain: "example.com", @@ -151,7 +147,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do flow: PlausibleWeb.Flows.review() ) - assert element_exists?(html, ~s|a[href="/example.com/settings/general"]|) + refute element_exists?(html, ~s|a[href="/example.com/settings/general"]|) assert element_exists?( html, diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs index 84e379b321fa..30d7bf05d325 100644 --- a/test/plausible_web/live/installation_test.exs +++ b/test/plausible_web/live/installation_test.exs @@ -246,11 +246,15 @@ defmodule PlausibleWeb.Live.InstallationTest do {"gtm", "Verify Tag Manager installation"}, {"npm", "Verify NPM installation"} ] do - test "submitting form with #{type} redirects to verification (EE)", %{ - conn: conn, - site: site - } do + test "submitting form with #{type} redirects to the dashboard with the verification banner (EE)", + %{ + conn: conn, + site: site + } do stub_dns() + + stub_lookup_a_records(site.domain) + stub_detection_manual() {lv, _html} = get_lv(conn, site, "?type=#{unquote(type)}") @@ -270,7 +274,8 @@ defmodule PlausibleWeb.Live.InstallationTest do assert_redirect( lv, - Routes.site_path(conn, :verification, site.domain, + Routes.stats_path(conn, :stats, site.domain, + verify_installation: true, flow: "provisioning", installation_type: unquote(type) ) @@ -280,7 +285,10 @@ defmodule PlausibleWeb.Live.InstallationTest do end @tag :ce_build_only - test "submitting the form redirects to verification (CE)", %{conn: conn, site: site} do + test "submitting the form redirects straight to the dashboard, no banner (CE)", %{ + conn: conn, + site: site + } do {lv, _html} = get_lv(conn, site) lv @@ -294,13 +302,7 @@ defmodule PlausibleWeb.Live.InstallationTest do } }) - assert_redirect( - lv, - Routes.site_path(conn, :verification, site.domain, - flow: "provisioning", - installation_type: "manual" - ) - ) + assert_redirect(lv, Routes.stats_path(conn, :stats, site.domain)) end test "404 goal gets created regardless of user options", %{conn: conn, site: site} do @@ -331,10 +333,11 @@ defmodule PlausibleWeb.Live.InstallationTest do assert Enum.any?(goals, &(&1.event_name == "404")) end - test "submitting form with review flow redirects to verification with flow param", %{ - conn: conn, - site: site - } do + test "submitting form with review flow redirects to the dashboard with the flow param preserved", + %{ + conn: conn, + site: site + } do on_ee do stub_dns() stub_detection_manual() @@ -356,13 +359,20 @@ defmodule PlausibleWeb.Live.InstallationTest do } }) - assert_redirect( - lv, - Routes.site_path(conn, :verification, site.domain, - flow: "review", - installation_type: "manual" + on_ee do + assert_redirect( + lv, + Routes.stats_path(conn, :stats, site.domain, + verify_installation: true, + flow: "review", + installation_type: "manual" + ) ) - ) + end + + on_ce do + assert_redirect(lv, Routes.stats_path(conn, :stats, site.domain)) + end end @tag :ee_only diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 1e2928306459..bde788fa7ddc 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -9,18 +9,21 @@ defmodule PlausibleWeb.Live.VerificationTest do setup [:create_user, :log_in, :create_site] - # @verify_button ~s|button#launch-verification-button[phx-click="launch-verification"]| @retry_button ~s|a[phx-click="retry"]| - # @go_to_dashboard_button ~s|a[href$="?skip_to_dashboard=true"]| @progress ~s|#verification-ui p#progress| @awaiting ~s|#verification-ui span#awaiting| - @heading ~s|#verification-ui h2| + @heading ~s|#verification-ui h3| describe "GET /:domain" do @tag :ee_only - test "static verification screen renders", %{conn: conn, site: site} do + test "static verification banner renders on a freshly provisioned site", %{ + conn: conn, + site: site + } do resp = - get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to) + conn + |> no_slowdown() + |> get("/#{site.domain}?verify_installation=true") |> html_response(200) assert text_of_element(resp, @progress) =~ @@ -30,12 +33,10 @@ defmodule PlausibleWeb.Live.VerificationTest do end @tag :ce_build_only - test "static verification screen renders (ce)", %{conn: conn, site: site} do - resp = - get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to) - |> html_response(200) + test "no verification banner renders on CE", %{conn: conn, site: site} do + resp = get(conn, "/#{site.domain}") |> html_response(200) - assert resp =~ "Awaiting your first pageview …" + refute resp =~ "verification-ui" end end @@ -57,39 +58,35 @@ defmodule PlausibleWeb.Live.VerificationTest do "We're visiting your site to ensure that everything is working" end - @tag :ce_build_only - test "LiveView mounts (ce)", %{conn: conn, site: site} do - {_, html} = get_lv(conn, site) - assert html =~ "Awaiting your first pageview …" - end - @tag :ee_only - test "from custom URL input form to verification", %{conn: conn, site: site} do + test "clicking the custom URL link swaps in the form, submitting kicks off a new run", %{ + conn: conn, + site: site + } do stub_dns() + stub_lookup_a_records(site.domain) + stub_verification_result(%{ "completed" => false, "error" => %{"message" => "Error"} }) - # Get liveview with ?custom_url=true query param - {:ok, lv, html} = - conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true") + {lv, _html} = get_lv(conn, site) verifying_installation_text = "Verifying your installation" - # Assert form is rendered instead of kicking off verification automatically - assert html =~ "Enter Your Custom URL" + html = lv |> render_click("show-custom-url-form") + + assert html =~ "Enter your custom URL" assert html =~ ~s[value="https://#{site.domain}"] assert html =~ ~s[placeholder="https://#{site.domain}"] refute html =~ verifying_installation_text - # Submit custom URL form html = lv |> element("form") |> render_submit(%{"custom_url" => "https://abc.de"}) - # Should now show verification progress and hide custom URL form assert html =~ verifying_installation_text - refute html =~ "Enter Your Custom URL" + refute html =~ "Enter your custom URL" end @tag :ee_only @@ -161,12 +158,18 @@ defmodule PlausibleWeb.Live.VerificationTest do html = render(lv) refute text_of_element(html, @awaiting) =~ "Awaiting your first pageview" - refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}/") + refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}") end - test "will redirect when first pageview arrives", %{conn: conn, site: site} do + @tag :ee_only + test "shows success in place (no redirect) once first pageview arrives", %{ + conn: conn, + site: site + } do stub_dns() + stub_lookup_a_records(site.domain) + stub_verification_result(%{ "completed" => true, "trackerIsInHtml" => true, @@ -184,30 +187,19 @@ defmodule PlausibleWeb.Live.VerificationTest do assert eventually(fn -> html = render(lv) - - { - text(html) =~ "Awaiting", - html - } + {text(html) =~ "Awaiting", html} end) populate_stats(site, [ build(:pageview) ]) - assert_redirect(lv, Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)) - end - - @tag :ce_build_only - test "will redirect when first pageview arrives (ce)", %{conn: conn, site: site} do - {:ok, lv} = kick_off_live_verification(conn, site) - - html = render(lv) - assert text(html) =~ "Awaiting your first pageview …" - - populate_stats(site, [build(:pageview)]) + assert eventually(fn -> + html = render(lv) + {not (text(html) =~ "Awaiting your first pageview"), html} + end) - assert_redirect(lv, Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)) + refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}") end for {installation_type_param, expected_text, saved_installation_type} <- [ @@ -261,7 +253,7 @@ defmodule PlausibleWeb.Live.VerificationTest do kick_off_live_verification( conn, site, - "?installation_type=#{unquote(installation_type_param)}" + "installation_type=#{unquote(installation_type_param)}" ) assert html = @@ -285,17 +277,31 @@ defmodule PlausibleWeb.Live.VerificationTest do end defp get_lv(conn, site, qs \\ nil) do - {:ok, lv, html} = conn |> no_slowdown() |> live("/#{site.domain}/verification#{qs}") + {:ok, lv, html} = + conn |> no_slowdown() |> as_live() |> live(verification_path(site, qs)) + {lv, html} end defp kick_off_live_verification(conn, site, qs \\ nil) do {:ok, lv, _html} = - conn |> no_slowdown() |> no_delay() |> live("/#{site.domain}/verification#{qs}") + conn |> no_slowdown() |> no_delay() |> as_live() |> live(verification_path(site, qs)) {:ok, lv} end + # `PlausibleWeb.Live.Verification` is rendered via `live_render/3` from a + # plain controller-rendered page rather than a live router route, so it + # never carries the `:live_module` assign `Phoenix.LiveViewTest.live/2` + # looks for. Mirrors the same workaround used in other live_render-embedded + # LiveView tests (e.g. props_settings_test.exs). + defp as_live(conn), do: assign(conn, :live_module, PlausibleWeb.Live.Verification) + + defp verification_path(site, nil), do: "/#{site.domain}?verify_installation=true" + + defp verification_path(site, qs), + do: "/#{site.domain}?verify_installation=true&#{qs}" + defp no_slowdown(conn) do Plug.Conn.put_private(conn, :slowdown, 0) end From 6611ad447357e618079d32cd71cb9d24f3445a24 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 15 Jul 2026 13:13:57 +0100 Subject: [PATCH 02/43] move custom url input into the banner --- extra/lib/plausible_web/live/verification.ex | 36 +------------ .../live/components/verification.ex | 27 +++++++++- .../live/components/verification_test.exs | 35 ++++++++++++ test/plausible_web/live/verification_test.exs | 54 +++++++++++++------ 4 files changed, 100 insertions(+), 52 deletions(-) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 21e4179aeb2a..7c1a2604f6a4 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -5,8 +5,6 @@ defmodule PlausibleWeb.Live.Verification do """ use PlausibleWeb, :live_view - import PlausibleWeb.Components.Generic - alias Plausible.InstallationSupport.{State, Verification} @component PlausibleWeb.Live.Components.Verification @@ -82,9 +80,7 @@ defmodule PlausibleWeb.Live.Verification do defp verification_content(assigns) do ~H""" - <.custom_url_form :if={@custom_url_input?} domain={@domain} /> <.live_component - :if={not @custom_url_input?} module={@component} installation_type={@installation_type} domain={@domain} @@ -93,6 +89,7 @@ defmodule PlausibleWeb.Live.Verification do flow={@flow} awaiting_first_pageview?={not @has_pageviews?} super_admin?={@super_admin?} + custom_url_input?={@custom_url_input?} /> """ end @@ -254,35 +251,4 @@ defmodule PlausibleWeb.Live.Verification do defp has_pageviews?(site) do Plausible.Stats.Clickhouse.has_pageviews?(site) end - - defp custom_url_form(assigns) do - ~H""" - <.notice title="Enter your custom URL" theme={:gray} class="mb-4"> - <:icon> - - -

    - Please enter the URL where your website with the Plausible script is located. -

    -
    - - - -
    - - """ - end end diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index a4fd779d3880..3692a7c2c75d 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -10,6 +10,7 @@ defmodule PlausibleWeb.Live.Components.Verification do alias Plausible.InstallationSupport.{State, Result} import PlausibleWeb.Components.Generic + import PlausibleWeb.Live.Components.Form @container_id "verification-ui" # Dismissing hides the banner immediately and strips `verify_installation` @@ -37,6 +38,7 @@ defmodule PlausibleWeb.Live.Components.Verification do attr(:flow, :string, default: "") attr(:installation_type, :string, default: nil) attr(:awaiting_first_pageview?, :boolean, default: false) + attr(:custom_url_input?, :boolean, default: false) def render(assigns) do assigns = @@ -71,6 +73,7 @@ defmodule PlausibleWeb.Live.Components.Verification do installation_type={@installation_type} super_admin?={@super_admin?} verification_state={@verification_state} + custom_url_input?={@custom_url_input?} />
    """ @@ -122,9 +125,29 @@ defmodule PlausibleWeb.Live.Components.Verification do /> <:actions> - <.button_link mt?={false} href="#" phx-click="retry" size="sm"> + <.button_link :if={not @custom_url_input?} mt?={false} href="#" phx-click="retry" size="sm"> Verify installation again +
    + <.input + type="url" + name="custom_url" + id="custom_url" + aria-label="Website URL" + required + mt?={false} + width="w-44" + placeholder={"https://#{@domain}"} + value={"https://#{@domain}"} + /> + <.button type="submit" mt?={false} size="sm"> + Verify installation again + +

    {List.first(@interpretation.recommendations).text}.  @@ -134,7 +157,7 @@ defmodule PlausibleWeb.Live.Components.Verification do

    Is your website located at a different URL? diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs index bf3d6a1a479d..f897ecbfbe7e 100644 --- a/test/plausible_web/live/components/verification_test.exs +++ b/test/plausible_web/live/components/verification_test.exs @@ -137,6 +137,41 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do assert element_exists?(html, ~s|a#verify-custom-url-link[phx-click="show-custom-url-form"]|) end + test "renders the custom URL input inline, retry button becomes the form's submit button, hides the prompt link" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "example.com", + diagnostics: %Verification.Diagnostics{ + plausible_is_on_window: false, + plausible_is_initialized: false, + service_error: %{code: :domain_not_found} + } + }) + + html = + render_component(@component, + domain: "example.com", + finished?: true, + success?: false, + interpretation: interpretation, + custom_url_input?: true + ) + + refute element_exists?(html, "#verify-custom-url-link") + refute element_exists?(html, ~s|a[phx-click="retry"]|) + + assert text_of_element(html, ~s|form[phx-submit="verify-custom-url"] button[type="submit"]|) =~ + "Verify installation again" + + assert element_exists?( + html, + ~s|form[phx-submit="verify-custom-url"] input[name="custom_url"]| + ) + + assert text_of_attr(html, ~s|form[phx-submit="verify-custom-url"] input|, "value") =~ + "https://example.com" + end + test "offers an installation-instructions escape path on failure, no more settings link" do html = render_component(@component, diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index bde788fa7ddc..841b2d7e6ab9 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -14,6 +14,8 @@ defmodule PlausibleWeb.Live.VerificationTest do @awaiting ~s|#verification-ui span#awaiting| @heading ~s|#verification-ui h3| + @in_progress_text "Verifying your installation" + describe "GET /:domain" do @tag :ee_only test "static verification banner renders on a freshly provisioned site", %{ @@ -29,7 +31,7 @@ defmodule PlausibleWeb.Live.VerificationTest do assert text_of_element(resp, @progress) =~ "We're visiting your site to ensure that everything is working" - assert resp =~ "Verifying your installation" + assert resp =~ @in_progress_text end @tag :ce_build_only @@ -52,41 +54,63 @@ defmodule PlausibleWeb.Live.VerificationTest do {_, html} = get_lv(conn, site) - assert html =~ "Verifying your installation" + assert html =~ @in_progress_text assert text_of_element(html, @progress) =~ "We're visiting your site to ensure that everything is working" end @tag :ee_only - test "clicking the custom URL link swaps in the form, submitting kicks off a new run", %{ - conn: conn, - site: site - } do + test "clicking the custom URL link reveals an inline form next to the retry button, submitting kicks off a new run", + %{ + conn: conn, + site: site + } do stub_dns() stub_lookup_a_records(site.domain) stub_verification_result(%{ - "completed" => false, - "error" => %{"message" => "Error"} + "completed" => true, + "trackerIsInHtml" => false, + "plausibleIsOnWindow" => false, + "plausibleIsInitialized" => false }) - {lv, _html} = get_lv(conn, site) + {:ok, lv} = kick_off_live_verification(conn, site) - verifying_installation_text = "Verifying your installation" + assert eventually(fn -> + html = render(lv) + + { + text_of_element(html, @heading) =~ "We couldn't detect Plausible on your site", + html + } + end) html = lv |> render_click("show-custom-url-form") - assert html =~ "Enter your custom URL" + refute html =~ @in_progress_text + + refute element_exists?(html, @retry_button) + refute element_exists?(html, "#verify-custom-url-link") + + assert element_exists?( + html, + ~s|form[phx-submit="verify-custom-url"] input[name="custom_url"]| + ) + assert html =~ ~s[value="https://#{site.domain}"] assert html =~ ~s[placeholder="https://#{site.domain}"] - refute html =~ verifying_installation_text - html = lv |> element("form") |> render_submit(%{"custom_url" => "https://abc.de"}) + lv + |> element("form[phx-submit='verify-custom-url']") + |> render_submit(%{"custom_url" => "https://abc.de"}) - assert html =~ verifying_installation_text - refute html =~ "Enter your custom URL" + assert eventually(fn -> + html = render(lv) + {html =~ @in_progress_text, html} + end) end @tag :ee_only From 9f3c5674b21c92cb3de7d739d3e760e3646e1810 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 15 Jul 2026 14:01:05 +0100 Subject: [PATCH 03/43] improve notice styles --- extra/lib/plausible_web/live/verification.ex | 2 +- .../live/components/verification.ex | 100 ++++++++++-------- .../live/components/verification_test.exs | 12 +-- 3 files changed, 61 insertions(+), 53 deletions(-) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 7c1a2604f6a4..38330c26fff8 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -8,7 +8,7 @@ defmodule PlausibleWeb.Live.Verification do alias Plausible.InstallationSupport.{State, Verification} @component PlausibleWeb.Live.Components.Verification - @slowdown_for_frequent_checking :timer.seconds(5) + @slowdown_for_frequent_checking :timer.seconds(0) @use_portal? Mix.env() not in [:test, :ce_test] def mount( diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index 3692a7c2c75d..466e23678f50 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -83,7 +83,9 @@ defmodule PlausibleWeb.Live.Components.Verification do ~H""" <.notice title="Verifying your installation" theme={:gray}> <:icon> -

    +
    +
    +

    {@message}

    @@ -116,17 +118,27 @@ defmodule PlausibleWeb.Live.Components.Verification do do: List.first(@interpretation.errors), else: "We couldn't verify your installation" } - theme={:red} + theme={:yellow} > <:icon> - + - <:actions> - <.button_link :if={not @custom_url_input?} mt?={false} href="#" phx-click="retry" size="sm"> - Verify installation again +

    + {List.first(@interpretation.recommendations).text}.  + <.styled_link href={List.first(@interpretation.recommendations).url} new_tab={true}> + Learn more + +

    +
    + <.button_link + :if={not @custom_url_input?} + mt?={false} + href="#" + phx-click="retry" + theme="secondary" + size="sm" + > + Check again
    - <.button type="submit" mt?={false} size="sm"> - Verify installation again + <.button type="submit" mt?={false} theme="secondary" size="sm"> + Check again
    - -

    - {List.first(@interpretation.recommendations).text}.  - <.styled_link href={List.first(@interpretation.recommendations).url} new_tab={true}> - Learn more - -

    -

    - - Is your website located at a different URL? - <.styled_link href="#" phx-click="show-custom-url-form" id="verify-custom-url-link"> - Click here - - - = 3}> - Need further help with your installation? - <.styled_link href="https://plausible.io/contact"> - Contact us - - - - Need to see installation instructions again? - <.styled_link href={ - Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, - flow: @flow, - installation_type: @installation_type - ) +

    +
    +
      +
    • - Click here - - -

      + Is your website located at a different URL? + <.styled_link href="#" phx-click="show-custom-url-form" id="verify-custom-url-link"> + Click here + +
    • +
    • = 3}> + Need further help with your installation? + <.styled_link href="https://plausible.io/contact"> + Contact us + +
    • +
    • + Need to see installation instructions again? + <.styled_link href={ + Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, + flow: @flow, + installation_type: @installation_type + ) + }> + Click here + +
    • +
    +
    <.super_admin_diagnostics :if={@super_admin? and not is_nil(@verification_state)} verification_state={@verification_state} @@ -194,7 +202,7 @@ defmodule PlausibleWeb.Live.Components.Verification do defp super_admin_diagnostics(assigns) do ~H"""
    diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs index f897ecbfbe7e..a9fda0c55af4 100644 --- a/test/plausible_web/live/components/verification_test.exs +++ b/test/plausible_web/live/components/verification_test.exs @@ -11,7 +11,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do @component PlausibleWeb.Live.Components.Verification @progress ~s|#verification-ui p#progress| - @pulsating_circle ~s|div#verification-ui div.pulsating-circle| + @loading_spinner ~s|div#verification-ui div.loading| @check_circle ~s|div#verification-ui #check-circle| @error_circle ~s|div#verification-ui #error-circle| @recommendations ~s|#recommendation| @@ -24,8 +24,8 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do assert text_of_element(html, @progress) == "We're visiting your site to ensure that everything is working" - assert element_exists?(html, @pulsating_circle) - refute class_of_element(html, @pulsating_circle) =~ "hidden" + assert element_exists?(html, @loading_spinner) + refute class_of_element(html, @loading_spinner) =~ "hidden" refute element_exists?(html, @recommendations) refute element_exists?(html, @check_circle) refute element_exists?(html, @super_admin_report) @@ -33,7 +33,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do test "renders error badge on error" do html = render_component(@component, domain: "example.com", success?: false, finished?: true) - refute element_exists?(html, @pulsating_circle) + refute element_exists?(html, @loading_spinner) refute element_exists?(html, @check_circle) refute element_exists?(html, @recommendations) assert element_exists?(html, @error_circle) @@ -92,7 +92,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do finished?: true ) - refute element_exists?(html, @pulsating_circle) + refute element_exists?(html, @loading_spinner) assert element_exists?(html, @check_circle) end @@ -161,7 +161,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do refute element_exists?(html, ~s|a[phx-click="retry"]|) assert text_of_element(html, ~s|form[phx-submit="verify-custom-url"] button[type="submit"]|) =~ - "Verify installation again" + "Check again" assert element_exists?( html, From 894bcd52a7a2a22f9471d5a9449cccc2ab37fa83 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 16 Jul 2026 13:23:38 +0100 Subject: [PATCH 04/43] expandable instructions --- extra/lib/plausible_web/live/verification.ex | 5 + lib/plausible_web/components/generic.ex | 32 +++ .../live/components/verification.ex | 230 +++++++++++++----- .../live/installation/instructions.ex | 102 ++++---- 4 files changed, 258 insertions(+), 111 deletions(-) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 38330c26fff8..2fa2b5dd2392 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -36,6 +36,9 @@ defmodule PlausibleWeb.Live.Verification do super_admin? = Plausible.Auth.super_admin?(current_user) has_pageviews? = has_pageviews?(site) + tracker_script_configuration = + PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site) + socket = assign(socket, url_to_verify: nil, @@ -44,6 +47,7 @@ defmodule PlausibleWeb.Live.Verification do domain: domain, has_pageviews?: has_pageviews?, component: @component, + tracker_script_configuration: tracker_script_configuration, installation_type: get_installation_type(session["installation_type"], site), report_to: self(), delay: private[:delay] || 500, @@ -90,6 +94,7 @@ defmodule PlausibleWeb.Live.Verification do awaiting_first_pageview?={not @has_pageviews?} super_admin?={@super_admin?} custom_url_input?={@custom_url_input?} + tracker_script_configuration={@tracker_script_configuration} /> """ end diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index ffdf5277c06a..2dd458386723 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -850,6 +850,38 @@ defmodule PlausibleWeb.Components.Generic do """ end + attr :id, :string, required: true + attr :text, :string, required: true + attr :rows, :integer, required: true + attr :resizable, :boolean, default: false + + def copyable_readonly_text_area(assigns) do + ~H""" +
    + + + + + + COPY + + +
    + """ + end + slot :title slot :subtitle slot :inner_block, required: true diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index 466e23678f50..f55c66a10a57 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -7,7 +7,10 @@ defmodule PlausibleWeb.Live.Components.Verification do use Plausible alias PlausibleWeb.Router.Helpers, as: Routes + alias PlausibleWeb.Components.Icons + alias PlausibleWeb.Live.Installation.Instructions alias Plausible.InstallationSupport.{State, Result} + alias Plausible.Site.TrackerScriptConfiguration import PlausibleWeb.Components.Generic import PlausibleWeb.Live.Components.Form @@ -39,6 +42,7 @@ defmodule PlausibleWeb.Live.Components.Verification do attr(:installation_type, :string, default: nil) attr(:awaiting_first_pageview?, :boolean, default: false) attr(:custom_url_input?, :boolean, default: false) + attr(:tracker_script_configuration, TrackerScriptConfiguration, default: nil) def render(assigns) do assigns = @@ -74,6 +78,7 @@ defmodule PlausibleWeb.Live.Components.Verification do super_admin?={@super_admin?} verification_state={@verification_state} custom_url_input?={@custom_url_input?} + tracker_script_configuration={@tracker_script_configuration} />
    """ @@ -111,6 +116,14 @@ defmodule PlausibleWeb.Live.Components.Verification do end defp render_failed(assigns) do + assigns = + assign( + assigns, + :expandable_instructions?, + assigns.installation_type in ["manual", "gtm"] and + not is_nil(assigns.tracker_script_configuration) + ) + ~H""" <.notice title={ @@ -129,68 +142,32 @@ defmodule PlausibleWeb.Live.Components.Verification do Learn more

    -
    - <.button_link - :if={not @custom_url_input?} - mt?={false} - href="#" - phx-click="retry" - theme="secondary" - size="sm" - > - Check again - -
    - <.input - type="url" - name="custom_url" - id="custom_url" - aria-label="Website URL" - required - mt?={false} - width="w-44" - placeholder={"https://#{@domain}"} - value={"https://#{@domain}"} +
    +
    + <.retry_form_or_button custom_url_input?={@custom_url_input?} domain={@domain} /> + <.expand_installation_instructions_button + :if={@expandable_instructions?} + installation_type={@installation_type} /> - <.button type="submit" mt?={false} theme="secondary" size="sm"> - Check again - - -
    -
    -
      -
    • - Is your website located at a different URL? - <.styled_link href="#" phx-click="show-custom-url-form" id="verify-custom-url-link"> - Click here - -
    • -
    • = 3}> - Need further help with your installation? - <.styled_link href="https://plausible.io/contact"> - Contact us - -
    • -
    • - Need to see installation instructions again? - <.styled_link href={ - Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, - flow: @flow, - installation_type: @installation_type - ) - }> - Click here - -
    • -
    + <.review_instructions_link + :if={@installation_type in ["wordpress", "npm"]} + installation_type={@installation_type} + /> +
    + <.expandable_installation_instructions + :if={@expandable_instructions?} + installation_type={@installation_type} + tracker_script_configuration={@tracker_script_configuration} + />
    + <.additional_help_links + custom_url_input?={@custom_url_input?} + interpretation={@interpretation} + attempts={@attempts} + domain={@domain} + flow={@flow} + installation_type={@installation_type} + /> <.super_admin_diagnostics :if={@super_admin? and not is_nil(@verification_state)} verification_state={@verification_state} @@ -199,6 +176,131 @@ defmodule PlausibleWeb.Live.Components.Verification do """ end + defp retry_form_or_button(%{custom_url_input?: true} = assigns) do + ~H""" +
    + <.input + type="url" + name="custom_url" + id="custom_url" + aria-label="Website URL" + required + mt?={false} + width="w-44" + placeholder={"https://#{@domain}"} + value={"https://#{@domain}"} + /> + <.button type="submit" mt?={false} theme="secondary" size="sm"> + Check again + +
    + """ + end + + defp retry_form_or_button(assigns) do + ~H""" + <.button_link + mt?={false} + href="#" + phx-click="retry" + theme="secondary" + size="sm" + > + Check again + + """ + end + + defp expand_installation_instructions_button(assigns) do + ~H""" + + <.button_link + mt?={false} + href="#" + theme="ghost" + size="sm" + class="hover:bg-gray-900/10 dark:hover:bg-white/10 hover:border-transparent dark:hover:border-transparent" + > + + {review_instructions_label(@installation_type)} + + + + + + + + + + """ + end + + defp review_instructions_link(assigns) do + ~H""" + <.button_link + mt?={false} + href={install_help_href(@installation_type)} + theme="ghost" + size="sm" + target="_blank" + rel="noopener noreferrer" + class="hover:bg-gray-900/10 dark:hover:bg-white/10 hover:border-transparent dark:hover:border-transparent" + > + Review instructions + + """ + end + + defp expandable_installation_instructions(assigns) do + ~H""" +
    + + +
    + """ + end + + defp additional_help_links(assigns) do + ~H""" +
    +
      +
    • + Is your website located at a different URL? + <.styled_link href="#" phx-click="show-custom-url-form" id="verify-custom-url-link"> + Click here + +
    • +
    • = 3}> + Need further help with your installation? + <.styled_link href="https://plausible.io/contact"> + Contact us + +
    • +
    • + Want to choose another installation method? + <.styled_link href={ + Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, + flow: @flow, + installation_type: @installation_type + ) + }> + Click here + +
    • +
    +
    + """ + end + defp super_admin_diagnostics(assigns) do ~H"""
    - <.snippet_form - text={render_snippet(@tracker_script_configuration_form.data)} - rows={6} - resizable={true} - /> + <.copy_snippet_box tracker_script_configuration={@tracker_script_configuration_form.data} /> <.h2 class="mt-8 text-sm font-medium">Optional measurements <.script_config_control field={@tracker_script_configuration_form[:outbound_links]} @@ -129,38 +127,56 @@ defmodule PlausibleWeb.Live.Installation.Instructions do Tag Manager installation
    - - We've detected your website is using Google Tag Manager. Here's how to integrate Plausible: - - - Using Google Tag Manager? Here's how to integrate Plausible: - -
    - <.focus_list> - <:item> - Copy your site's Script ID: - <.snippet_form - text={@tracker_script_configuration_form.data.id} - rows={1} - resizable={false} - /> - + <.gtm_instructions_content + recommended_installation_type={@recommended_installation_type} + tracker_script_configuration={@tracker_script_configuration_form.data} + /> +
    + """ + end - <:item> - <.styled_link href="https://plausible.io/gtm-template" new_tab={true}> - Install the Plausible template in GTM - - + attr :recommended_installation_type, :string, required: true + attr :tracker_script_configuration, TrackerScriptConfiguration, required: true - <:item> - Paste your Script ID into the template and click the button below to verify your installation. - - -
    + def gtm_instructions_content(assigns) do + ~H""" + + We've detected your website is using Google Tag Manager. Here's how to integrate Plausible: + + + Using Google Tag Manager? Here's how to integrate Plausible: + +
    + <.gtm_instructions_content_inner tracker_script_configuration={@tracker_script_configuration} />
    """ end + def gtm_instructions_content_inner(assigns) do + ~H""" + <.copyable_readonly_text_area + id="script-config-id" + text={@tracker_script_configuration.id} + rows={1} + /> + <.focus_list> + <:item> + Copy your site's Script ID from above + + + <:item> + <.styled_link href="https://plausible.io/gtm-template" new_tab={true}> + Install the Plausible template in GTM + + + + <:item> + Paste your Script ID into the template + + + """ + end + def npm_instructions(assigns) do ~H""" <.title class="my-4"> @@ -236,27 +252,11 @@ defmodule PlausibleWeb.Live.Installation.Instructions do """ end - defp snippet_form(assigns) do - ~H""" -
    - + def copy_snippet_box(assigns) do + assigns = assign(assigns, :text, render_snippet(assigns.tracker_script_configuration)) - - - - COPY - - -
    + ~H""" + <.copyable_readonly_text_area id="snippet" text={@text} rows={6} resizable={true} /> """ end From a5b23b3f16434baf0be8a35104e0d5bc08763a91 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 16 Jul 2026 13:45:32 +0100 Subject: [PATCH 05/43] stop polling pageviews --- extra/lib/plausible_web/live/verification.ex | 34 ------- lib/plausible/stats/clickhouse.ex | 16 ---- .../live/components/verification.ex | 5 - test/plausible_web/live/verification_test.exs | 91 +------------------ 4 files changed, 1 insertion(+), 145 deletions(-) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 2fa2b5dd2392..2f1b2bc13bbe 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -34,7 +34,6 @@ defmodule PlausibleWeb.Live.Verification do private = Map.get(socket.private.connect_info, :private, %{}) super_admin? = Plausible.Auth.super_admin?(current_user) - has_pageviews? = has_pageviews?(site) tracker_script_configuration = PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site) @@ -45,7 +44,6 @@ defmodule PlausibleWeb.Live.Verification do site: site, super_admin?: super_admin?, domain: domain, - has_pageviews?: has_pageviews?, component: @component, tracker_script_configuration: tracker_script_configuration, installation_type: get_installation_type(session["installation_type"], site), @@ -55,7 +53,6 @@ defmodule PlausibleWeb.Live.Verification do flow: session["flow"] || "", checks_pid: nil, attempts: 0, - polling_pageviews?: false, custom_url_input?: false ) @@ -91,7 +88,6 @@ defmodule PlausibleWeb.Live.Verification do id="verification-standalone" attempts={@attempts} flow={@flow} - awaiting_first_pageview?={not @has_pageviews?} super_admin?={@super_admin?} custom_url_input?={@custom_url_input?} tracker_script_configuration={@tracker_script_configuration} @@ -170,10 +166,6 @@ defmodule PlausibleWeb.Live.Verification do def handle_info({:all_checks_done, %State{} = state}, socket) do interpretation = Verification.Checks.interpret_diagnostics(state) - if not socket.assigns.has_pageviews? do - schedule_pageviews_check(socket) - end - update_component(socket, finished?: true, success?: interpretation.ok?, @@ -184,19 +176,6 @@ defmodule PlausibleWeb.Live.Verification do {:noreply, assign(socket, checks_pid: nil)} end - def handle_info(:check_pageviews, socket) do - if has_pageviews?(socket.assigns.site) do - {:noreply, assign(socket, has_pageviews?: true, polling_pageviews?: false)} - else - socket = - socket - |> assign(polling_pageviews?: false) - |> schedule_pageviews_check() - - {:noreply, socket} - end - end - @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types() |> Enum.map(&String.to_atom/1) defp get_installation_type(installation_type, site) do @@ -222,15 +201,6 @@ defmodule PlausibleWeb.Live.Verification do end end - defp schedule_pageviews_check(socket) do - if socket.assigns.polling_pageviews? do - socket - else - Process.send_after(self(), :check_pageviews, socket.assigns.delay * 2) - assign(socket, polling_pageviews?: true) - end - end - defp reset_component(socket) do update_component(socket, message: "We're visiting your site to ensure that everything is working", @@ -252,8 +222,4 @@ defmodule PlausibleWeb.Live.Verification do defp launch_delayed(socket) do Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay) end - - defp has_pageviews?(site) do - Plausible.Stats.Clickhouse.has_pageviews?(site) - end end diff --git a/lib/plausible/stats/clickhouse.ex b/lib/plausible/stats/clickhouse.ex index 375c9e9f47fc..fadd473f867d 100644 --- a/lib/plausible/stats/clickhouse.ex +++ b/lib/plausible/stats/clickhouse.ex @@ -191,20 +191,4 @@ defmodule Plausible.Stats.Clickhouse do def current_visitors_12h(site) do Stats.current_visitors(site, Duration.new!(hour: -12)) end - - def has_pageviews?(site) do - # This function is currently only used in installation verification - # which is not accessible for consolidated views. - true = Plausible.Sites.regular?(site) - - ClickhouseRepo.exists?( - from(e in "events_v2", - where: - e.site_id == ^site.id and - e.name == "pageview" and - e.timestamp >= - ^site.native_stats_start_at - ) - ) - end end diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index f55c66a10a57..c14dec4df100 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -40,7 +40,6 @@ defmodule PlausibleWeb.Live.Components.Verification do attr(:attempts, :integer, default: 0) attr(:flow, :string, default: "") attr(:installation_type, :string, default: nil) - attr(:awaiting_first_pageview?, :boolean, default: false) attr(:custom_url_input?, :boolean, default: false) attr(:tracker_script_configuration, TrackerScriptConfiguration, default: nil) @@ -63,7 +62,6 @@ defmodule PlausibleWeb.Live.Components.Verification do <.render_progress :if={not @finished?} message={@message} /> <.render_success :if={@finished? and @success?} - awaiting_first_pageview?={@awaiting_first_pageview?} domain={@domain} super_admin?={@super_admin?} verification_state={@verification_state} @@ -104,9 +102,6 @@ defmodule PlausibleWeb.Live.Components.Verification do Your installation is working and visitors are being counted accurately. - - Awaiting your first pageview... - <.super_admin_diagnostics :if={@super_admin? and not is_nil(@verification_state)} verification_state={@verification_state} diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 841b2d7e6ab9..48134b7a42b9 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -11,7 +11,6 @@ defmodule PlausibleWeb.Live.VerificationTest do @retry_button ~s|a[phx-click="retry"]| @progress ~s|#verification-ui p#progress| - @awaiting ~s|#verification-ui span#awaiting| @heading ~s|#verification-ui h3| @in_progress_text "Verifying your installation" @@ -134,96 +133,8 @@ defmodule PlausibleWeb.Live.VerificationTest do assert eventually(fn -> html = render(lv) - - { - text_of_element(html, @awaiting) =~ - "Awaiting your first pageview", - html - } + {html =~ "Success!", html} end) - - html = render(lv) - assert html =~ "Success!" - assert html =~ "Awaiting your first pageview" - end - - @tag :ee_only - test "won't await first pageview if site has pageviews", %{conn: conn, site: site} do - populate_stats(site, [ - build(:pageview) - ]) - - stub_dns() - - stub_verification_result(%{ - "completed" => true, - "trackerIsInHtml" => true, - "plausibleIsOnWindow" => true, - "plausibleIsInitialized" => true, - "testEvent" => %{ - "normalizedBody" => %{ - "domain" => site.domain - }, - "responseStatus" => 200 - } - }) - - {:ok, lv} = kick_off_live_verification(conn, site) - - assert eventually(fn -> - html = render(lv) - - { - text(html) =~ "Success", - html - } - end) - - html = render(lv) - - refute text_of_element(html, @awaiting) =~ "Awaiting your first pageview" - refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}") - end - - @tag :ee_only - test "shows success in place (no redirect) once first pageview arrives", %{ - conn: conn, - site: site - } do - stub_dns() - - stub_lookup_a_records(site.domain) - - stub_verification_result(%{ - "completed" => true, - "trackerIsInHtml" => true, - "plausibleIsOnWindow" => true, - "plausibleIsInitialized" => true, - "testEvent" => %{ - "normalizedBody" => %{ - "domain" => site.domain - }, - "responseStatus" => 200 - } - }) - - {:ok, lv} = kick_off_live_verification(conn, site) - - assert eventually(fn -> - html = render(lv) - {text(html) =~ "Awaiting", html} - end) - - populate_stats(site, [ - build(:pageview) - ]) - - assert eventually(fn -> - html = render(lv) - {not (text(html) =~ "Awaiting your first pageview"), html} - end) - - refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}") end for {installation_type_param, expected_text, saved_installation_type} <- [ From 37f8951fadeb5aa03f8ddddbaf18608d4898b9ce Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 16 Jul 2026 14:33:15 +0100 Subject: [PATCH 06/43] rely on tracker_script_config object for installation_method --- extra/lib/plausible_web/live/verification.ex | 28 ++----- .../controllers/stats_controller.ex | 3 +- .../live/components/verification.ex | 7 +- lib/plausible_web/live/installation.ex | 11 +-- .../live/components/verification_test.exs | 2 +- test/plausible_web/live/installation_test.exs | 6 +- test/plausible_web/live/verification_test.exs | 77 +++++++------------ 7 files changed, 41 insertions(+), 93 deletions(-) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 2f1b2bc13bbe..e4ed5d8e91db 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -46,7 +46,6 @@ defmodule PlausibleWeb.Live.Verification do domain: domain, component: @component, tracker_script_configuration: tracker_script_configuration, - installation_type: get_installation_type(session["installation_type"], site), report_to: self(), delay: private[:delay] || 500, slowdown: private[:slowdown] || 500, @@ -83,7 +82,7 @@ defmodule PlausibleWeb.Live.Verification do ~H""" <.live_component module={@component} - installation_type={@installation_type} + installation_type={get_installation_type(@tracker_script_configuration)} domain={@domain} id="verification-standalone" attempts={@attempts} @@ -139,7 +138,7 @@ defmodule PlausibleWeb.Live.Verification do Verification.Checks.run( socket.assigns.url_to_verify, domain, - socket.assigns.installation_type, + get_installation_type(socket.assigns.tracker_script_configuration), report_to: report_to, slowdown: socket.assigns.slowdown ) @@ -178,26 +177,13 @@ defmodule PlausibleWeb.Live.Verification do @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types() |> Enum.map(&String.to_atom/1) - defp get_installation_type(installation_type, site) do - cond do - installation_type in PlausibleWeb.Tracker.supported_installation_types() -> - installation_type - - (saved_installation_type = get_saved_installation_type(site)) in @supported_installation_types_atoms -> - Atom.to_string(saved_installation_type) - - true -> - PlausibleWeb.Tracker.fallback_installation_type() - end - end - - defp get_saved_installation_type(site) do - case PlausibleWeb.Tracker.get_tracker_script_configuration(site) do - %{installation_type: installation_type} -> - installation_type + defp get_installation_type(tracker_script_configuration) do + case tracker_script_configuration.installation_type do + type when type in @supported_installation_types_atoms -> + Atom.to_string(type) _ -> - nil + PlausibleWeb.Tracker.fallback_installation_type() end end diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index f96cd2f94855..a0abc0f9665a 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -118,8 +118,7 @@ defmodule PlausibleWeb.StatsController do verify_installation?: verify_installation?, verification_session: %{ "domain" => site.domain, - "flow" => conn.params["flow"], - "installation_type" => conn.params["installation_type"] + "flow" => conn.params["flow"] } ) end diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index c14dec4df100..905539a2643d 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -22,7 +22,6 @@ defmodule PlausibleWeb.Live.Components.Verification do @dismiss_onclick "document.getElementById('#{@container_id}').classList.add('hidden');" <> "var u = new window.URL(window.location.href);" <> "u.searchParams.delete('verify_installation');" <> - "u.searchParams.delete('installation_type');" <> "u.searchParams.delete('flow');" <> "window.history.replaceState(null, '', u);" @@ -161,7 +160,6 @@ defmodule PlausibleWeb.Live.Components.Verification do attempts={@attempts} domain={@domain} flow={@flow} - installation_type={@installation_type} /> <.super_admin_diagnostics :if={@super_admin? and not is_nil(@verification_state)} @@ -283,10 +281,7 @@ defmodule PlausibleWeb.Live.Components.Verification do
  • Want to choose another installation method? <.styled_link href={ - Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, - flow: @flow, - installation_type: @installation_type - ) + Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, flow: @flow) }> Click here diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index b34c6f11ae22..f88e6aaf1a79 100644 --- a/lib/plausible_web/live/installation.ex +++ b/lib/plausible_web/live/installation.ex @@ -321,12 +321,7 @@ defmodule PlausibleWeb.Live.Installation do end def handle_event("submit", %{"tracker_script_configuration" => params}, socket) do - config = - PlausibleWeb.Tracker.update_script_configuration!( - socket.assigns.site, - params, - :installation - ) + PlausibleWeb.Tracker.update_script_configuration!(socket.assigns.site, params, :installation) domain = socket.assigns.site.domain @@ -334,11 +329,9 @@ defmodule PlausibleWeb.Live.Installation do on_ee do Routes.stats_path(socket, :stats, domain, verify_installation: true, - flow: socket.assigns.flow, - installation_type: config.installation_type + flow: socket.assigns.flow ) else - _need_to_use_variable_on_ce = config Routes.stats_path(socket, :stats, domain, []) end diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs index a9fda0c55af4..da442e8de625 100644 --- a/test/plausible_web/live/components/verification_test.exs +++ b/test/plausible_web/live/components/verification_test.exs @@ -186,7 +186,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do assert element_exists?( html, - ~s|a[href="/example.com/installation?flow=review&installation_type=wordpress"]| + ~s|a[href="/example.com/installation?flow=review"]| ) end end diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs index 30d7bf05d325..763b6dfdfac9 100644 --- a/test/plausible_web/live/installation_test.exs +++ b/test/plausible_web/live/installation_test.exs @@ -276,8 +276,7 @@ defmodule PlausibleWeb.Live.InstallationTest do lv, Routes.stats_path(conn, :stats, site.domain, verify_installation: true, - flow: "provisioning", - installation_type: unquote(type) + flow: "provisioning" ) ) end @@ -364,8 +363,7 @@ defmodule PlausibleWeb.Live.InstallationTest do lv, Routes.stats_path(conn, :stats, site.domain, verify_installation: true, - flow: "review", - installation_type: "manual" + flow: "review" ) ) end diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 48134b7a42b9..7843fbb873ce 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -137,34 +137,21 @@ defmodule PlausibleWeb.Live.VerificationTest do end) end - for {installation_type_param, expected_text, saved_installation_type} <- [ - {"manual", - "Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", - nil}, - {"npm", - "Please make sure you've initialized Plausible on your site, or verify your installation manually.", - nil}, - {"gtm", - "Please make sure you've configured the GTM template correctly, or verify your installation manually.", - nil}, - {"wordpress", - "Please make sure you've enabled the plugin, or verify your installation manually.", - nil}, - # trusts param over saved installation type - {"wordpress", - "Please make sure you've enabled the plugin, or verify your installation manually.", + for {expected_text, saved_installation_type} <- [ + {"Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + "manual"}, + {"Please make sure you've initialized Plausible on your site, or verify your installation manually.", "npm"}, - # falls back to saved installation type if no param - {"", - "Please make sure you've initialized Plausible on your site, or verify your installation manually.", - "npm"}, - # falls back to manual if no param and no saved installation type - {"", - "Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + {"Please make sure you've configured the GTM template correctly, or verify your installation manually.", + "gtm"}, + {"Please make sure you've enabled the plugin, or verify your installation manually.", + "wordpress"}, + # falls back to manual when there's no saved installation type + {"Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", nil} ] do @tag :ee_only - test "eventually fails to verify installation (?installation_type=#{installation_type_param}) if saved installation type is #{inspect(saved_installation_type)}", + test "eventually fails to verify installation if saved installation type is #{inspect(saved_installation_type)}", %{ conn: conn, site: site @@ -184,24 +171,17 @@ defmodule PlausibleWeb.Live.VerificationTest do }) end - {:ok, lv} = - kick_off_live_verification( - conn, - site, - "installation_type=#{unquote(installation_type_param)}" - ) - - assert html = - eventually(fn -> - html = render(lv) - {html =~ "", html} - - { - text_of_element(html, @heading) =~ - "We couldn't detect Plausible on your site", - html - } - end) + {:ok, lv} = kick_off_live_verification(conn, site) + + html = + eventually(fn -> + html = render(lv) + + { + text_of_element(html, @heading) =~ "We couldn't detect Plausible on your site", + html + } + end) assert element_exists?(html, @retry_button) @@ -211,16 +191,16 @@ defmodule PlausibleWeb.Live.VerificationTest do end end - defp get_lv(conn, site, qs \\ nil) do + defp get_lv(conn, site) do {:ok, lv, html} = - conn |> no_slowdown() |> as_live() |> live(verification_path(site, qs)) + conn |> no_slowdown() |> as_live() |> live(verification_path(site)) {lv, html} end - defp kick_off_live_verification(conn, site, qs \\ nil) do + defp kick_off_live_verification(conn, site) do {:ok, lv, _html} = - conn |> no_slowdown() |> no_delay() |> as_live() |> live(verification_path(site, qs)) + conn |> no_slowdown() |> no_delay() |> as_live() |> live(verification_path(site)) {:ok, lv} end @@ -232,10 +212,7 @@ defmodule PlausibleWeb.Live.VerificationTest do # LiveView tests (e.g. props_settings_test.exs). defp as_live(conn), do: assign(conn, :live_module, PlausibleWeb.Live.Verification) - defp verification_path(site, nil), do: "/#{site.domain}?verify_installation=true" - - defp verification_path(site, qs), - do: "/#{site.domain}?verify_installation=true&#{qs}" + defp verification_path(site), do: "/#{site.domain}?verify_installation=true" defp no_slowdown(conn) do Plug.Conn.put_private(conn, :slowdown, 0) From dc490f7393c69b826193242da9e0a815fd587900 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 20 Jul 2026 09:46:18 +0100 Subject: [PATCH 07/43] proper dismiss logic --- .../js/dashboard/verification/portal.test.tsx | 75 +++++++++++++++++++ assets/js/dashboard/verification/portal.tsx | 55 ++++++++++++-- assets/js/liveview/live_socket.js | 11 +++ extra/lib/plausible_web/live/verification.ex | 14 +++- .../controllers/stats_controller.ex | 8 +- .../live/components/verification.ex | 63 +++++++++++----- test/plausible_web/live/verification_test.exs | 63 ++++++++++++++++ 7 files changed, 258 insertions(+), 31 deletions(-) create mode 100644 assets/js/dashboard/verification/portal.test.tsx diff --git a/assets/js/dashboard/verification/portal.test.tsx b/assets/js/dashboard/verification/portal.test.tsx new file mode 100644 index 000000000000..48dca2a3cf01 --- /dev/null +++ b/assets/js/dashboard/verification/portal.test.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { act, render, screen } from '@testing-library/react' +import { useLocation } from 'react-router-dom' +import { TestContextProviders } from '../../../test-utils/app-context-providers' +import { + VERIFICATION_FINISHED_EVENT, + VerificationLiveViewPortal +} from './portal' + +function LocationDisplay() { + const location = useLocation() + return
    {location.pathname + location.search}
    +} + +function renderWithInitialEntry(initialEntry: string) { + render( + <> + + + , + { + wrapper: (props) => ( + + ) + } + ) +} + +function dispatchVerificationFinished(queryParams: string[]) { + act(() => { + window.dispatchEvent( + new CustomEvent(VERIFICATION_FINISHED_EVENT, { + detail: { queryParams } + }) + ) + }) +} + +test('drops exactly the query params named in the event detail, leaving every other param untouched', () => { + renderWithInitialEntry( + '/some-domain?f=contains,os,a&f=contains,page,/&verify_installation=true&flow=provisioning&comparison=year_over_year' + ) + + dispatchVerificationFinished(['verify_installation', 'flow']) + + expect(screen.getByTestId('location').textContent).toBe( + '/?f=contains,os,a&f=contains,page,/&comparison=year_over_year' + ) +}) + +test('does nothing when none of the named params are present', () => { + renderWithInitialEntry('/some-domain?comparison=year_over_year') + + dispatchVerificationFinished(['verify_installation', 'flow']) + + expect(screen.getByTestId('location').textContent).toBe( + '/?comparison=year_over_year' + ) +}) + +test('drops only verify_installation, keeping a real param that happens to be a prefix of it', () => { + renderWithInitialEntry( + '/some-domain?verify_installation=true&verify_installation_extra=keep-me' + ) + + dispatchVerificationFinished(['verify_installation']) + + expect(screen.getByTestId('location').textContent).toBe( + '/?verify_installation_extra=keep-me' + ) +}) diff --git a/assets/js/dashboard/verification/portal.tsx b/assets/js/dashboard/verification/portal.tsx index 8e9a54858276..f32aba92c2a6 100644 --- a/assets/js/dashboard/verification/portal.tsx +++ b/assets/js/dashboard/verification/portal.tsx @@ -1,9 +1,50 @@ -import React from 'react' +import React, { useEffect } from 'react' +import { useAppNavigate } from '../navigation/use-app-navigate' -export const VerificationLiveViewPortal = React.memo( - () => { - return
    +type VerificationFinishedDetail = { + /** + * Exact query param names to drop from the URL when verification banner + * disappears. See: PlausibleWeb.Live.Components.Verification.query_params/0 + */ + queryParams: string[] +} - }, - () => true -) +export const VERIFICATION_FINISHED_EVENT = 'verification-finished' + +/** + * Renders the portal target into which the verification LiveView (see + * lib/plausible_web/live/components/verification.ex) gets teleported. + * Also helps that LiveView out with cleaning up after itself: clearing + * its one-time query params through React Router. + */ +export const VerificationLiveViewPortal = React.memo(() => { + const navigate = useAppNavigate() + + useEffect(() => { + function handleVerificationFinished(event: Event) { + const { queryParams } = (event as CustomEvent) + .detail + + navigate({ + search: (search) => { + const nextSearch = { ...search } + queryParams.forEach((param) => delete nextSearch[param]) + return nextSearch + } + }) + } + + window.addEventListener( + VERIFICATION_FINISHED_EVENT, + handleVerificationFinished + ) + + return () => + window.removeEventListener( + VERIFICATION_FINISHED_EVENT, + handleVerificationFinished + ) + }, [navigate]) + + return
    +}) diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 2379bb596e6d..b68184de75f3 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -17,6 +17,17 @@ let csrfToken = document.querySelector("meta[name='csrf-token']") let websocketUrl = document.querySelector("meta[name='websocket-url']") if (csrfToken && websocketUrl) { let Hooks = { Modal, Dropdown } + + // Lets a LiveView tell the client to tear down the websocket connection + // once it's done with it (e.g. PlausibleWeb.Live.Verification, once its + // banner has been dismissed) - the server-side process then terminates + // gracefully. + Hooks.DisconnectSocket = { + mounted() { + this.handleEvent('disconnect-liveview', () => liveSocket.disconnect()) + } + } + let Uploaders = {} Uploaders.S3 = function (entries, onViewError) { entries.forEach((entry) => { diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index e4ed5d8e91db..cfe347b186c3 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -52,7 +52,8 @@ defmodule PlausibleWeb.Live.Verification do flow: session["flow"] || "", checks_pid: nil, attempts: 0, - custom_url_input?: false + custom_url_input?: false, + dismissed?: false ) if connected?(socket) do @@ -66,7 +67,7 @@ defmodule PlausibleWeb.Live.Verification do assigns = assign(assigns, :use_portal?, @use_portal?) ~H""" -
    +
    <%= if @use_portal? do %> <.portal id="verification-portal-source" target="#verification-portal-target"> <.verification_content {assigns} /> @@ -90,6 +91,7 @@ defmodule PlausibleWeb.Live.Verification do super_admin?={@super_admin?} custom_url_input?={@custom_url_input?} tracker_script_configuration={@tracker_script_configuration} + dismissed?={@dismissed?} /> """ end @@ -108,6 +110,14 @@ defmodule PlausibleWeb.Live.Verification do {:noreply, assign(socket, custom_url_input?: true)} end + # Once dismissed, this LiveView has nothing left to do - and since it's + # the only LiveView on the dashboard page, there's no reason to keep + # the websocket connection open for the rest of the browsing session. + def handle_event("dismiss", _, socket) do + update_component(socket, dismissed?: true) + {:noreply, socket |> assign(dismissed?: true) |> push_event("disconnect-liveview", %{})} + end + def handle_event("verify-custom-url", %{"custom_url" => custom_url}, socket) do socket = socket diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index a0abc0f9665a..f30053664b24 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -116,10 +116,10 @@ defmodule PlausibleWeb.StatsController do limited_to_segment_id: nil, connect_live_socket: verify_installation?, verify_installation?: verify_installation?, - verification_session: %{ - "domain" => site.domain, - "flow" => conn.params["flow"] - } + verification_session: + PlausibleWeb.Live.Components.Verification.query_params() + |> Map.new(&{&1, conn.params[&1]}) + |> Map.put("domain", site.domain) ) end end diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index 905539a2643d..74f27c706a02 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -16,14 +16,11 @@ defmodule PlausibleWeb.Live.Components.Verification do import PlausibleWeb.Live.Components.Form @container_id "verification-ui" - # Dismissing hides the banner immediately and strips `verify_installation` - # from the URL (the same param that got it rendered in the first place - - # see PlausibleWeb.StatsController), so a refresh doesn't bring it back. - @dismiss_onclick "document.getElementById('#{@container_id}').classList.add('hidden');" <> - "var u = new window.URL(window.location.href);" <> - "u.searchParams.delete('verify_installation');" <> - "u.searchParams.delete('flow');" <> - "window.history.replaceState(null, '', u);" + + # All query params the verification LiveView needs must be listed here, so + # they can be cleaned up from the URL once verification finishes. + @query_params ~w(verify_installation flow) + def query_params, do: @query_params attr(:domain, :string, required: true) @@ -41,23 +38,17 @@ defmodule PlausibleWeb.Live.Components.Verification do attr(:installation_type, :string, default: nil) attr(:custom_url_input?, :boolean, default: false) attr(:tracker_script_configuration, TrackerScriptConfiguration, default: nil) + attr(:dismissed?, :boolean, default: false) def render(assigns) do assigns = assigns - |> assign(:dismiss_onclick, @dismiss_onclick) |> assign(:container_id, @container_id) + |> assign(:query_params, @query_params) ~H""" -
    - +
    + <.dismiss_button container_id={@container_id} query_params={@query_params} /> <.render_progress :if={not @finished?} message={@message} /> <.render_success :if={@finished? and @success?} @@ -81,6 +72,42 @@ defmodule PlausibleWeb.Live.Components.Verification do """ end + # The action of dismissing the verification banner consists of 4 + # independent things: + # + # 1. Client-side: the inlined `onclick` instantly adds the `hidden` + # class straight to the container div. + # + # 2. Client-side: instantly dispatches a `verification-finished` + # window event so React router can clean up query params that are + # no longer needed (see assets/js/dashboard/verification/portal.tsx). + # Also makes sure that a refresh won't bring verification back. + # + # 3. Server-side (phx-click="dismiss"): sets `dismissed?` on this + # component's assigns, so it stays hidden even if a later + # `send_update` (e.g. :all_checks_done) re-renders it. + # + # 4. Server-side, same handler: tells the client to close the websocket + # connection, since the LiveView has nothing left to do. + defp dismiss_button(assigns) do + ~H""" + + """ + end + + defp dismiss_onclick(container_id, query_params) do + "document.getElementById('#{container_id}').classList.add('hidden');" <> + "window.dispatchEvent(new CustomEvent('verification-finished', { detail: { queryParams: #{Jason.encode!(query_params)} } }));" + end + defp render_progress(assigns) do ~H""" <.notice title="Verifying your installation" theme={:gray}> diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 7843fbb873ce..e20fe187a2c4 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -12,6 +12,7 @@ defmodule PlausibleWeb.Live.VerificationTest do @retry_button ~s|a[phx-click="retry"]| @progress ~s|#verification-ui p#progress| @heading ~s|#verification-ui h3| + @banner ~s|#verification-ui| @in_progress_text "Verifying your installation" @@ -137,6 +138,68 @@ defmodule PlausibleWeb.Live.VerificationTest do end) end + @tag :ee_only + test "the dismissed flag keeps the banner hidden even if a late update arrives while still connected", + %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + html = render(lv) + assert html =~ @in_progress_text + refute class_of_element(html, @banner) =~ "hidden" + + html = render_click(lv, "dismiss") + assert class_of_element(html, @banner) =~ "hidden" + + # This might look a bit counter-intuitive -- dismissing the banner + # closes the websocket connection and the LV process would normally + # die before the component gets notified of success. + + # However, `Phoenix.LiveViewTest` can't simulate a real socket closing, + # so the process here just stays alive regardless. What this guards is + # the defensive `dismissed?` gate itself: if this process is ever still + # around when a late update arrives, for whatever reason, the banner + # must stay hidden. + assert eventually(fn -> + html = render(lv) + {html =~ "Success!", html} + end) + + html = render(lv) + assert class_of_element(html, @banner) =~ "hidden" + end + + @tag :ee_only + test "dismissing tells the client to close the websocket connection", + %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => false, + "error" => %{"message" => "Error"} + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + render_click(lv, "dismiss") + + assert_push_event(lv, "disconnect-liveview", %{}) + end + for {expected_text, saved_installation_type} <- [ {"Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", "manual"}, From b41381e9b06418cae7bb97b11bf5c1d9255b37e3 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 20 Jul 2026 09:49:11 +0100 Subject: [PATCH 08/43] [revert me] debugging UI scenarios --- .../verification/checks.ex | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/extra/lib/plausible/installation_support/verification/checks.ex b/extra/lib/plausible/installation_support/verification/checks.ex index 2a3328d21bcb..6fe66711e03d 100644 --- a/extra/lib/plausible/installation_support/verification/checks.ex +++ b/extra/lib/plausible/installation_support/verification/checks.ex @@ -11,6 +11,24 @@ defmodule Plausible.InstallationSupport.Verification.Checks do @verify_installation_check_timeout 20_000 + # Local UI debugging only - set to one of the keys below to make every + # verification run return that canned interpretation, regardless of what + # the real check pipeline actually found. Handy for iterating on + # PlausibleWeb.Live.Verification's banner UI states. Must be `nil` on commit. + @debug_scenario nil + + @debug_scenarios %{ + 0 => :success, + 1 => %Verification.Diagnostics{}, + 2 => %Verification.Diagnostics{selected_installation_type: "wordpress"}, + 3 => %Verification.Diagnostics{ + plausible_is_on_window: false, + plausible_is_initialized: false, + service_error: %{code: :domain_not_found} + }, + 4 => %Verification.Diagnostics{disallowed_by_csp: true} + } + @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t() def run(url, data_domain, installation_type, opts \\ []) do # Timeout option for testing purposes @@ -59,6 +77,7 @@ defmodule Plausible.InstallationSupport.Verification.Checks do opts \\ [] ) do telemetry? = Keyword.get(opts, :telemetry?, true) + {diagnostics, url} = debug_override(diagnostics, data_domain, url) result = Verification.Diagnostics.interpret( @@ -97,4 +116,28 @@ defmodule Plausible.InstallationSupport.Verification.Checks do result end + + # Also overrides `url` to a clean, query-string-free one - otherwise the + # real check pipeline's cache-busting query param (?plausible_verification=...) + # leaks into canned error messages like "We couldn't find your website at ...". + defp debug_override(diagnostics, data_domain, url) do + case Map.get(@debug_scenarios, @debug_scenario) do + nil -> + {diagnostics, url} + + :success -> + { + %Verification.Diagnostics{ + test_event: %{ + "normalizedBody" => %{"domain" => data_domain}, + "responseStatus" => 200 + } + }, + "https://#{data_domain}" + } + + %Verification.Diagnostics{} = debug -> + {debug, "https://#{data_domain}"} + end + end end From e170662c9b74d05237a348daea887d175e301c9c Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 20 Jul 2026 10:01:08 +0100 Subject: [PATCH 09/43] rename verification (component) to verification_banner --- assets/js/dashboard/verification/portal.tsx | 2 +- extra/lib/plausible_web/live/verification.ex | 2 +- lib/plausible_web/controllers/stats_controller.ex | 2 +- .../components/{verification.ex => verification_banner.ex} | 2 +- .../{verification_test.exs => verification_banner_test.exs} | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename lib/plausible_web/live/components/{verification.ex => verification_banner.ex} (99%) rename test/plausible_web/live/components/{verification_test.exs => verification_banner_test.exs} (98%) diff --git a/assets/js/dashboard/verification/portal.tsx b/assets/js/dashboard/verification/portal.tsx index f32aba92c2a6..19d482ecb111 100644 --- a/assets/js/dashboard/verification/portal.tsx +++ b/assets/js/dashboard/verification/portal.tsx @@ -4,7 +4,7 @@ import { useAppNavigate } from '../navigation/use-app-navigate' type VerificationFinishedDetail = { /** * Exact query param names to drop from the URL when verification banner - * disappears. See: PlausibleWeb.Live.Components.Verification.query_params/0 + * disappears. See: PlausibleWeb.Live.Components.VerificationBanner.query_params/0 */ queryParams: string[] } diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index cfe347b186c3..57611d11f436 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -7,7 +7,7 @@ defmodule PlausibleWeb.Live.Verification do alias Plausible.InstallationSupport.{State, Verification} - @component PlausibleWeb.Live.Components.Verification + @component PlausibleWeb.Live.Components.VerificationBanner @slowdown_for_frequent_checking :timer.seconds(0) @use_portal? Mix.env() not in [:test, :ce_test] diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index f30053664b24..d1cabbde2a1c 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -117,7 +117,7 @@ defmodule PlausibleWeb.StatsController do connect_live_socket: verify_installation?, verify_installation?: verify_installation?, verification_session: - PlausibleWeb.Live.Components.Verification.query_params() + PlausibleWeb.Live.Components.VerificationBanner.query_params() |> Map.new(&{&1, conn.params[&1]}) |> Map.put("domain", site.domain) ) diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification_banner.ex similarity index 99% rename from lib/plausible_web/live/components/verification.ex rename to lib/plausible_web/live/components/verification_banner.ex index 74f27c706a02..eecca8992d88 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification_banner.ex @@ -1,4 +1,4 @@ -defmodule PlausibleWeb.Live.Components.Verification do +defmodule PlausibleWeb.Live.Components.VerificationBanner do @moduledoc """ This component is responsible for rendering the verification progress and diagnostics as a compact banner on top of the dashboard. diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_banner_test.exs similarity index 98% rename from test/plausible_web/live/components/verification_test.exs rename to test/plausible_web/live/components/verification_banner_test.exs index da442e8de625..61e1a34af423 100644 --- a/test/plausible_web/live/components/verification_test.exs +++ b/test/plausible_web/live/components/verification_banner_test.exs @@ -1,4 +1,4 @@ -defmodule PlausibleWeb.Live.Components.VerificationTest do +defmodule PlausibleWeb.Live.Components.VerificationBannerTest do use PlausibleWeb.ConnCase, async: true on_ee do @@ -8,7 +8,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do @moduletag :capture_log - @component PlausibleWeb.Live.Components.Verification + @component PlausibleWeb.Live.Components.VerificationBanner @progress ~s|#verification-ui p#progress| @loading_spinner ~s|div#verification-ui div.loading| From a56dabd11b9971ed3b8329783cadf2e9c95508de Mon Sep 17 00:00:00 2001 From: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:21:10 +0200 Subject: [PATCH 10/43] Refine verification banner UI and copy (#6525) * Refine verification banner UI and copy - Simplify failure CTAs: keep "Check again" as primary, add ghost "Review installation" (or "Try another URL" when a custom URL retry is offered); remove installation-guide, change-method, and view-snippet expandable logic - Render inline "verify your installation manually" / "review your installation" links inside recommendation text (offer_custom_url_input scenarios) via safe HTML helpers, avoiding HEEx whitespace pitfalls - Tighten diagnostics title and body copy - Move the "Setup pending" pill in the sites list to replace the percentage indicator; use the generic pill component - Extend the notice component with title_class override and a spinner slot; swap success icon to solid check-circle * improve templating logic --------- Co-authored-by: Robert Joonas --- .../plausible/installation_support/result.ex | 2 +- .../verification/diagnostics.ex | 129 +++++---- extra/lib/plausible_web/live/verification.ex | 2 - lib/plausible_web/components/generic.ex | 30 +- .../live/components/verification_banner.ex | 261 ++++++++---------- lib/plausible_web/live/sites.ex | 14 +- .../verification/checks_test.exs | 122 ++++---- .../verification/diagnostics_test.exs | 46 +++ .../components/verification_banner_test.exs | 88 ++++-- test/plausible_web/live/verification_test.exs | 16 +- 10 files changed, 415 insertions(+), 295 deletions(-) create mode 100644 test/plausible/installation_support/verification/diagnostics_test.exs diff --git a/extra/lib/plausible/installation_support/result.ex b/extra/lib/plausible/installation_support/result.ex index baa08d645ddd..a1fad54ca56b 100644 --- a/extra/lib/plausible/installation_support/result.ex +++ b/extra/lib/plausible/installation_support/result.ex @@ -6,7 +6,7 @@ defmodule Plausible.InstallationSupport.Result do ok?: false, data: nil, errors: [error.message], - recommendations: [%{text: error.recommendation, url: error.url}] + recommendations: [%{text: error.recommendation, inline_links: error.inline_links}] ok?: true, data: %{}, diff --git a/extra/lib/plausible/installation_support/verification/diagnostics.ex b/extra/lib/plausible/installation_support/verification/diagnostics.ex index 0773c07550f8..d464a0155fda 100644 --- a/extra/lib/plausible/installation_support/verification/diagnostics.ex +++ b/extra/lib/plausible/installation_support/verification/diagnostics.ex @@ -33,35 +33,56 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do """ @enforce_keys [:message, :recommendation] - defstruct [:message, :recommendation, :url] + defstruct [:message, :recommendation, inline_links: []] + + @required_link_prefix "https://plausible.io/" def new!(attrs) do message = Map.fetch!(attrs, :message) + recommendation = Map.fetch!(attrs, :recommendation) + inline_links = Map.get(attrs, :inline_links, []) if String.ends_with?(message, ".") do raise ArgumentError, "Error message must not end with a period: #{inspect(message)}" end - if String.ends_with?(attrs[:recommendation], ".") do + if String.ends_with?(recommendation, ".") do raise ArgumentError, - "Error recommendation must not end with a period: #{inspect(attrs[:recommendation])}" + "Error recommendation must not end with a period: #{inspect(recommendation)}" end - if is_binary(attrs[:url]) and not String.starts_with?(attrs[:url], "https://plausible.io") do - raise ArgumentError, - "Recommendation url must start with 'https://plausible.io': #{inspect(attrs[:url])}" + for %{text: text, href: href} <- inline_links do + if length(String.split(recommendation, text)) - 1 != 1 do + raise ArgumentError, + "Recommendation inline_links text #{inspect(text)} must appear exactly once in: #{inspect(recommendation)}" + end + + if not String.starts_with?(href, @required_link_prefix) do + raise ArgumentError, + "Recommendation inline_links href must start with '#{@required_link_prefix}': #{inspect(href)}" + end end struct!(__MODULE__, attrs) end end + @verify_manually_inline_link %{ + text: "verify your installation manually", + href: @verify_manually_url + } + @error_succeeds_only_after_cache_bust Error.new!(%{ message: "We detected an issue with your site's cache", recommendation: - "Please clear the cache for your site to ensure that your visitors will load the latest version of your site that has Plausible correctly installed", - url: - "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + "Clear the cache for your site to ensure your visitors load the latest version of your site with Plausible correctly installed. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + } + ] }) @spec interpret(t(), String.t(), String.t()) :: Result.t() @@ -121,17 +142,21 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do end @error_proxy_network_error Error.new!(%{ - message: - "We got an unexpected response from the proxy you are using for Plausible", + message: "We couldn't verify your proxied installation", recommendation: - "Please check that you've configured the proxied /event route correctly", - url: "https://plausible.io/docs/proxy/introduction" + "We received an unexpected response from your proxy. Check that you've configured the proxied /event route correctly. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: "https://plausible.io/docs/proxy/introduction" + } + ] }) @error_plausible_network_error Error.new!(%{ message: "We couldn't verify your website", recommendation: "Please try verifying again in a few minutes, or verify your installation manually", - url: @verify_manually_url + inline_links: [@verify_manually_inline_link] }) def interpret( @@ -174,11 +199,16 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do @error_csp_disallowed Error.new!(%{ message: - "We encountered an issue with your site's Content Security Policy (CSP)", + "Your site's Content Security Policy (CSP) is blocking Plausible", recommendation: - "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP", - url: - "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + "Add plausible.io to the list of allowed domains in your site's Content Security Policy to allow Plausible to collect analytics. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + } + ] }) def interpret( %__MODULE__{ @@ -191,10 +221,10 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do do: handled_error(@error_csp_disallowed) @error_domain_not_found Error.new!(%{ - message: "We couldn't find your website at <%= @attempted_url %>", + message: "We couldn't reach <%= @attempted_url %>", recommendation: - "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually", - url: @verify_manually_url + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you'll need to verify your installation manually", + inline_links: [@verify_manually_inline_link] }) def interpret(%__MODULE__{service_error: %{code: code}}, expected_domain, url) @@ -207,11 +237,10 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do end @error_browserless_network Error.new!(%{ - message: - "We couldn't verify your website at <%= @attempted_url %>", + message: "We couldn't verify <%= @attempted_url %>", recommendation: - "Accessing the website resulted in a network error. Please verify your installation manually", - url: @verify_manually_url + "We encountered a network error while trying to access your website. You can verify your installation manually", + inline_links: [@verify_manually_inline_link] }) def interpret( @@ -228,11 +257,10 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do end @error_browserless_temporary Error.new!(%{ - message: - "Our verification tool encountered a temporary service error", + message: "Our verification service is temporarily unavailable", recommendation: "Please try again in a few minutes or verify your installation manually", - url: @verify_manually_url + inline_links: [@verify_manually_inline_link] }) def interpret(%__MODULE__{service_error: %{code: code}}, _expected_domain, _url) @@ -241,11 +269,10 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do end @error_unexpected_page_response Error.new!(%{ - message: - "We couldn't verify your website at <%= @attempted_url %>", + message: "We couldn't verify <%= @attempted_url %>", recommendation: - "Accessing the website resulted in an unexpected status code <%= @page_response_status %>. Please check for anything that might be blocking us from reaching your site, like a firewall, authentication requirements, or CDN rules. If you'd prefer, you can skip this and verify your installation manually", - url: @verify_manually_url + "Accessing your website returned an unexpected status code (<%= @page_response_status %>). Check for anything that might be blocking our access to your site, such as a firewall, authentication requirements, or CDN rules. You can also verify your installation manually", + inline_links: [@verify_manually_inline_link] }) def interpret( @@ -290,26 +317,26 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do @error_plausible_not_found_for_manual Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've copied the snippet to the head of your site, or verify your installation manually", - url: @verify_manually_url + "Make sure you've copied the snippet to the head of your site, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_plausible_not_found_for_npm Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've initialized Plausible on your site, or verify your installation manually", - url: @verify_manually_url + "Make sure you've initialized Plausible on your site, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_plausible_not_found_for_gtm Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've configured the GTM template correctly, or verify your installation manually", - url: @verify_manually_url + "Make sure you've configured the GTM template correctly, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_plausible_not_found_for_wordpress Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've enabled the plugin, or verify your installation manually", - url: @verify_manually_url + "Make sure you've enabled the WordPress plugin, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) defp error_plausible_not_found(selected_installation_type) do case selected_installation_type do @@ -320,33 +347,33 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do end end - @unexpected_domain_message "Plausible test event is not for this site" + @unexpected_domain_message "Your Plausible snippet is configured for a different domain" @error_unexpected_domain_for_manual Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that the snippet on your site matches the installation instructions exactly, or verify your installation manually", - url: @verify_manually_url + "Check that the snippet on your site matches the one shown in the installation instructions, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_unexpected_domain_for_npm Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that you've initialized Plausible with the correct domain, or verify your installation manually", - url: @verify_manually_url + "Check you've initialized Plausible with the correct domain, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_unexpected_domain_for_gtm Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that you've entered the ID in the GTM template correctly, or verify your installation manually", - url: @verify_manually_url + "Check you've entered the ID in the GTM template correctly, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_unexpected_domain_for_wordpress Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that you've installed the WordPress plugin correctly, or verify your installation manually", - url: @verify_manually_url + "Check you've installed the WordPress plugin correctly, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) defp error_unexpected_domain(selected_installation_type) do case selected_installation_type do @@ -372,7 +399,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do %Result{ ok?: false, errors: [message], - recommendations: [%{text: recommendation, url: error.url}] + recommendations: [%{text: recommendation, inline_links: error.inline_links}] } end @@ -383,7 +410,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ok?: false, data: %{unhandled: true, browserless_issue: browserless_issue}, errors: [error.message], - recommendations: [%{text: error.recommendation, url: error.url}] + recommendations: [%{text: error.recommendation, inline_links: error.inline_links}] } end end diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 57611d11f436..ff4742bf3a56 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -83,14 +83,12 @@ defmodule PlausibleWeb.Live.Verification do ~H""" <.live_component module={@component} - installation_type={get_installation_type(@tracker_script_configuration)} domain={@domain} id="verification-standalone" attempts={@attempts} flow={@flow} super_admin?={@super_admin?} custom_url_input?={@custom_url_input?} - tracker_script_configuration={@tracker_script_configuration} dismissed?={@dismissed?} /> """ diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 2dd458386723..173e6a6a6f88 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -12,25 +12,37 @@ defmodule PlausibleWeb.Components.Generic do gray: %{ bg: "bg-gray-100 dark:bg-gray-800", icon: "text-gray-600 dark:text-gray-300", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-800 dark:text-gray-200 leading-5" }, + indigo: %{ + bg: "bg-indigo-100/60 dark:bg-indigo-900/40", + icon: "text-indigo-500", + title_text: "text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" + }, + green: %{ + bg: "bg-green-100/60 dark:bg-green-900/40", + icon: "text-green-500", + title_text: "text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" + }, yellow: %{ bg: "bg-yellow-100/60 dark:bg-yellow-900/40", icon: "text-yellow-500", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" }, red: %{ bg: "bg-red-100 dark:bg-red-900/30", icon: "text-red-600 dark:text-red-500", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" }, white: %{ bg: "bg-white dark:bg-gray-900 shadow-sm dark:shadow-none", icon: "text-gray-600 dark:text-gray-400", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-300 leading-5" } } @@ -222,6 +234,7 @@ defmodule PlausibleWeb.Components.Generic do attr(:show_icon, :boolean, default: true) attr(:class, :string, default: "") attr(:icon_class, :string, default: "") + attr(:title_class, :string, default: "") attr(:rest, :global) slot(:inner_block) slot(:actions) @@ -253,7 +266,14 @@ defmodule PlausibleWeb.Components.Generic do <% end %>
    -

    +

    {@title}

    diff --git a/lib/plausible_web/live/components/verification_banner.ex b/lib/plausible_web/live/components/verification_banner.ex index eecca8992d88..b40731c01db9 100644 --- a/lib/plausible_web/live/components/verification_banner.ex +++ b/lib/plausible_web/live/components/verification_banner.ex @@ -7,10 +7,7 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do use Plausible alias PlausibleWeb.Router.Helpers, as: Routes - alias PlausibleWeb.Components.Icons - alias PlausibleWeb.Live.Installation.Instructions alias Plausible.InstallationSupport.{State, Result} - alias Plausible.Site.TrackerScriptConfiguration import PlausibleWeb.Components.Generic import PlausibleWeb.Live.Components.Form @@ -35,9 +32,7 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do attr(:interpretation, Result, default: nil) attr(:attempts, :integer, default: 0) attr(:flow, :string, default: "") - attr(:installation_type, :string, default: nil) attr(:custom_url_input?, :boolean, default: false) - attr(:tracker_script_configuration, TrackerScriptConfiguration, default: nil) attr(:dismissed?, :boolean, default: false) def render(assigns) do @@ -62,11 +57,9 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do attempts={@attempts} domain={@domain} flow={@flow} - installation_type={@installation_type} super_admin?={@super_admin?} verification_state={@verification_state} custom_url_input?={@custom_url_input?} - tracker_script_configuration={@tracker_script_configuration} />
    """ @@ -94,7 +87,7 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do
    - <.site_stats sparkline={@sparkline} /> + <.site_stats sparkline={@sparkline} needs_verification?={@needs_verification?} />
    @@ -680,6 +674,7 @@ defmodule PlausibleWeb.Live.Sites do end attr(:sparkline, :any, required: true) + attr(:needs_verification?, :boolean, default: false) def site_stats(assigns) do ~H""" @@ -708,7 +703,10 @@ defmodule PlausibleWeb.Live.Sites do

    - <.percentage_change change={@sparkline.visitors_change} /> + <.pill :if={@needs_verification?} color={:yellow}> + Setup pending + + <.percentage_change :if={not @needs_verification?} change={@sparkline.visitors_change} />
  • diff --git a/test/plausible/installation_support/verification/checks_test.exs b/test/plausible/installation_support/verification/checks_test.exs index 01e01e4d7910..f3541ae1900e 100644 --- a/test/plausible/installation_support/verification/checks_test.exs +++ b/test/plausible/installation_support/verification/checks_test.exs @@ -13,6 +13,11 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do @expected_domain "example.com" @url_to_verify "https://#{@expected_domain}" + @verify_manually_url "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + @verify_manually_inline_link %{ + text: "verify your installation manually", + href: @verify_manually_url + } describe "URL check" do test "returns error when DNS check fails with domain not found error, offers custom URL input" do @@ -22,14 +27,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do ok?: false, data: %{offer_custom_url_input: true}, errors: [ - ^any(:string, ~r/We couldn't find your website at #{@url_to_verify}$/) + ^any(:string, ~r/We couldn't reach #{@url_to_verify}$/) ], recommendations: [ %{ text: - "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you can verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -49,14 +53,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do ok?: false, data: %{offer_custom_url_input: true}, errors: [ - ^any(:string, ~r/We couldn't find your website at #{url_to_verify}$/) + ^any(:string, ~r/We couldn't reach #{url_to_verify}$/) ], recommendations: [ %{ text: - "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you can verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -94,13 +97,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do for {installation_type, expected_recommendation} <- [ {"wordpress", - "Please check that you've installed the WordPress plugin correctly, or verify your installation manually"}, + "Check you've installed the WordPress plugin correctly, or verify your installation manually"}, {"gtm", - "Please check that you've entered the ID in the GTM template correctly, or verify your installation manually"}, + "Check you've entered the ID in the GTM template correctly, or verify your installation manually"}, {"npm", - "Please check that you've initialized Plausible with the correct domain, or verify your installation manually"}, + "Check you've initialized Plausible with the correct domain, or verify your installation manually"}, {"manual", - "Please check that the snippet on your site matches the installation instructions exactly, or verify your installation manually"} + "Check that the snippet on your site matches the one shown in the installation instructions, or verify your installation manually"} ] do test "returns error when test event domain doesn't match the expected domain, with recommendation for installation type: #{installation_type}" do verification_stub = @@ -119,12 +122,11 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: ["Plausible test event is not for this site"], + errors: ["Your Plausible snippet is configured for a different domain"], recommendations: [ %{ text: unquote(expected_recommendation), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + inline_links: [^@verify_manually_inline_link] } ] } = @@ -154,11 +156,16 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: [^any(:string, ~r/.*proxy.*/)], + errors: [^any(:string, ~r/.*proxied.*/)], recommendations: [ %{ text: ^any(:string, ~r/.*proxied.*/), - url: "https://plausible.io/docs/proxy/introduction" + inline_links: [ + %{ + text: "Learn more", + href: "https://plausible.io/docs/proxy/introduction" + } + ] } ] } = @@ -187,9 +194,9 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do errors: [^any(:string, ~r/.*couldn't verify.*/)], recommendations: [ %{ - text: ^any(:string, ~r/.*try verifying again.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + text: + "Please try verifying again in a few minutes, or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -213,9 +220,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: - "Please make sure you've copied the snippet to the head of your site, or verify your installation manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "Make sure you've copied the snippet to the head of your site, or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -236,14 +242,19 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, errors: [ - "We encountered an issue with your site's Content Security Policy (CSP)" + "Your site's Content Security Policy (CSP) is blocking Plausible" ], recommendations: [ %{ text: - "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP", - url: - "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + "Add plausible.io to the list of allowed domains in your site's Content Security Policy to allow Plausible to collect analytics. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + } + ] } ] } = @@ -261,15 +272,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, data: %{offer_custom_url_input: true}, - errors: [ - "We couldn't verify your website at https://example.com" - ], + errors: ["We couldn't verify https://example.com"], recommendations: [ %{ text: - "Accessing the website resulted in a network error. Please verify your installation manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "We encountered a network error while trying to access your website. You can verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -291,15 +299,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, data: %{offer_custom_url_input: true}, - errors: [ - "We couldn't verify your website at https://example.com" - ], + errors: ["We couldn't verify https://example.com"], recommendations: [ %{ text: - "Accessing the website resulted in an unexpected status code 403. Please check for anything that might be blocking us from reaching your site, like a firewall, authentication requirements, or CDN rules. If you'd prefer, you can skip this and verify your installation manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "Accessing your website returned an unexpected status code (403). Check for anything that might be blocking our access to your site, such as a firewall, authentication requirements, or CDN rules. You can also verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -309,13 +314,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do for {installation_type, expected_recommendation} <- [ {"wordpress", - "Please make sure you've enabled the plugin, or verify your installation manually"}, + "Make sure you've enabled the WordPress plugin, or verify your installation manually"}, {"gtm", - "Please make sure you've configured the GTM template correctly, or verify your installation manually"}, + "Make sure you've configured the GTM template correctly, or verify your installation manually"}, {"npm", - "Please make sure you've initialized Plausible on your site, or verify your installation manually"}, + "Make sure you've initialized Plausible on your site, or verify your installation manually"}, {"manual", - "Please make sure you've copied the snippet to the head of your site, or verify your installation manually"} + "Make sure you've copied the snippet to the head of your site, or verify your installation manually"} ] do test "returns error \"We couldn't detect Plausible on your site\" when plausible_is_on_window is false (with best guess recommendation for installation type: #{installation_type})" do verification_stub = @@ -334,8 +339,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: unquote(expected_recommendation), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + inline_links: [^@verify_manually_inline_link] } ] } = @@ -363,8 +367,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: unquote(expected_recommendation), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + inline_links: [^@verify_manually_inline_link] } ] } = @@ -426,8 +429,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: ^any(:string, ~r/.*cache.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + } + ] } ] } = run_checks(verification_stub) |> Checks.interpret_diagnostics() @@ -484,12 +492,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: [^any(:string, ~r/.*temporary service error.*/)], + errors: [^any(:string, ~r/.*temporarily unavailable.*/)], recommendations: [ %{ - text: ^any(:string, ~r/.*in a few minutes.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + text: + "Please try again in a few minutes or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = Checks.interpret_diagnostics(state) @@ -514,12 +522,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: [^any(:string, ~r/.*temporary service error.*/)], + errors: [^any(:string, ~r/.*temporarily unavailable.*/)], recommendations: [ %{ - text: ^any(:string, ~r/.*in a few minutes.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + text: + "Please try again in a few minutes or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = Checks.interpret_diagnostics(state) diff --git a/test/plausible/installation_support/verification/diagnostics_test.exs b/test/plausible/installation_support/verification/diagnostics_test.exs new file mode 100644 index 000000000000..bead8dfa1df0 --- /dev/null +++ b/test/plausible/installation_support/verification/diagnostics_test.exs @@ -0,0 +1,46 @@ +defmodule Plausible.InstallationSupport.Verification.DiagnosticsTest do + use ExUnit.Case, async: true + + alias Plausible.InstallationSupport.Verification.Diagnostics.Error + + describe "Error.new!/1" do + test "accepts a recommendation whose inline_links text appears exactly once" do + assert %Error{} = + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs for more info", + inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] + }) + end + + test "raises when inline_links text isn't found in the recommendation" do + assert_raise ArgumentError, ~r/must appear exactly once/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the manual for more info", + inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] + }) + end + end + + test "raises when inline_links text appears more than once in the recommendation" do + assert_raise ArgumentError, ~r/must appear exactly once/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs, or check the docs again", + inline_links: [%{text: "the docs", href: "https://plausible.io/docs"}] + }) + end + end + + test "raises when inline_links href doesn't point at plausible.io" do + assert_raise ArgumentError, ~r/must start with/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs for more info", + inline_links: [%{text: "docs", href: "https://example.com/docs"}] + }) + end + end + end +end diff --git a/test/plausible_web/live/components/verification_banner_test.exs b/test/plausible_web/live/components/verification_banner_test.exs index 61e1a34af423..0ecdf1bc8a58 100644 --- a/test/plausible_web/live/components/verification_banner_test.exs +++ b/test/plausible_web/live/components/verification_banner_test.exs @@ -11,9 +11,8 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do @component PlausibleWeb.Live.Components.VerificationBanner @progress ~s|#verification-ui p#progress| - @loading_spinner ~s|div#verification-ui div.loading| - @check_circle ~s|div#verification-ui #check-circle| - @error_circle ~s|div#verification-ui #error-circle| + @loading_spinner ~s|#verification-ui svg.animate-spin| + @check_circle ~s|#verification-ui #check-circle| @recommendations ~s|#recommendation| @super_admin_report ~s|#super-admin-report| @@ -22,24 +21,23 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do assert element_exists?(html, @progress) assert text_of_element(html, @progress) == - "We're visiting your site to ensure that everything is working" + "We're visiting your site to ensure that everything is working..." assert element_exists?(html, @loading_spinner) - refute class_of_element(html, @loading_spinner) =~ "hidden" refute element_exists?(html, @recommendations) refute element_exists?(html, @check_circle) refute element_exists?(html, @super_admin_report) end - test "renders error badge on error" do + test "renders failed state without progress spinner" do html = render_component(@component, domain: "example.com", success?: false, finished?: true) refute element_exists?(html, @loading_spinner) refute element_exists?(html, @check_circle) refute element_exists?(html, @recommendations) - assert element_exists?(html, @error_circle) + assert html =~ "We couldn't verify your installation" end - test "renders diagnostic interpretation" do + test "renders diagnostic interpretation with inline verify link and standalone review-installation sentence" do interpretation = Verification.Checks.interpret_diagnostics(%State{ url: "https://example.com", @@ -56,11 +54,60 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do ) assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) - assert recommendation =~ "check that the domain you entered is correct" + assert recommendation =~ "Check that the URL is correct and publicly accessible" + assert recommendation =~ "verify your installation manually" + refute recommendation =~ "review your installation" + assert recommendation =~ "See your installation instructions again here" + + assert element_exists?( + html, + ~s|#recommendation a[href="https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"]| + ) + + assert element_exists?( + html, + ~s|#recommendation a[href="/example.com/installation?flow="]| + ) refute element_exists?(html, @super_admin_report) end + test "renders inline verify-manually link when the recommendation mentions it (no custom URL retry)" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "https://example.com", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{ + plausible_is_on_window: false, + selected_installation_type: "manual" + } + }) + + refute Map.get(interpretation.data || %{}, :offer_custom_url_input) == true + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation + ) + + assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) + assert recommendation =~ "Make sure you've copied the snippet" + assert recommendation =~ "verify your installation manually" + refute recommendation =~ "review your installation" + refute recommendation =~ "Learn more" + refute recommendation =~ "See your installation instructions again here" + + assert element_exists?( + html, + ~s|#recommendation a[href="https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"]| + ) + + refute element_exists?(html, ~s|#recommendation a[href^="/example.com/installation"]|) + end + test "renders super-admin report" do state = %State{ url: "https://example.com", @@ -99,20 +146,20 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do test "renders a progress message" do html = render_component(@component, domain: "example.com", message: "Arbitrary message") - assert text_of_element(html, @progress) == "Arbitrary message" + assert text_of_element(html, @progress) == "Arbitrary message..." end - test "renders contact link on >3 attempts" do + test "renders contact link on >=3 attempts" do html = render_component(@component, domain: "example.com", attempts: 2, finished?: true) - refute html =~ "Need further help with your installation?" + refute html =~ "Need help?" refute element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) html = render_component(@component, domain: "example.com", attempts: 3, finished?: true) - assert html =~ "Need further help with your installation?" + assert html =~ "Need help?" assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) end - test "renders a click-to-show-form link to verify installation at a different URL" do + test "renders a Try another URL ghost button when a custom URL retry is offered" do interpretation = Verification.Checks.interpret_diagnostics(%State{ url: "example.com", @@ -133,11 +180,12 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do interpretation: interpretation ) - assert text_of_element(html, "#verify-custom-url-link") =~ "Click here" + assert text_of_element(html, "#verify-custom-url-link") =~ "Try another URL" assert element_exists?(html, ~s|a#verify-custom-url-link[phx-click="show-custom-url-form"]|) + refute html =~ "Review installation" end - test "renders the custom URL input inline, retry button becomes the form's submit button, hides the prompt link" do + test "renders the custom URL input inline, replacing Check again with the Verify URL submit button, and hides the secondary action" do interpretation = Verification.Checks.interpret_diagnostics(%State{ url: "example.com", @@ -159,9 +207,10 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do refute element_exists?(html, "#verify-custom-url-link") refute element_exists?(html, ~s|a[phx-click="retry"]|) + refute html =~ "Review installation" assert text_of_element(html, ~s|form[phx-submit="verify-custom-url"] button[type="submit"]|) =~ - "Check again" + "Verify URL" assert element_exists?( html, @@ -172,13 +221,12 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do "https://example.com" end - test "offers an installation-instructions escape path on failure, no more settings link" do + test "offers a Review installation ghost button on failure by default" do html = render_component(@component, domain: "example.com", success?: false, finished?: true, - installation_type: "wordpress", flow: PlausibleWeb.Flows.review() ) @@ -188,6 +236,8 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do html, ~s|a[href="/example.com/installation?flow=review"]| ) + + assert html =~ "Review installation" end end end diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index e20fe187a2c4..7ee23fcc3a03 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -134,7 +134,7 @@ defmodule PlausibleWeb.Live.VerificationTest do assert eventually(fn -> html = render(lv) - {html =~ "Success!", html} + {html =~ "Tracking is active on your site", html} end) end @@ -176,7 +176,7 @@ defmodule PlausibleWeb.Live.VerificationTest do # must stay hidden. assert eventually(fn -> html = render(lv) - {html =~ "Success!", html} + {html =~ "Tracking is active on your site", html} end) html = render(lv) @@ -201,16 +201,16 @@ defmodule PlausibleWeb.Live.VerificationTest do end for {expected_text, saved_installation_type} <- [ - {"Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + {"Make sure you've copied the snippet to the head of your site, or verify your installation manually.", "manual"}, - {"Please make sure you've initialized Plausible on your site, or verify your installation manually.", + {"Make sure you've initialized Plausible on your site, or verify your installation manually.", "npm"}, - {"Please make sure you've configured the GTM template correctly, or verify your installation manually.", + {"Make sure you've configured the GTM template correctly, or verify your installation manually.", "gtm"}, - {"Please make sure you've enabled the plugin, or verify your installation manually.", + {"Make sure you've enabled the WordPress plugin, or verify your installation manually.", "wordpress"}, # falls back to manual when there's no saved installation type - {"Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + {"Make sure you've copied the snippet to the head of your site, or verify your installation manually.", nil} ] do @tag :ee_only @@ -248,7 +248,7 @@ defmodule PlausibleWeb.Live.VerificationTest do assert element_exists?(html, @retry_button) - assert html =~ htmlize_quotes(unquote(expected_text)) + assert text_of_element(html, "#recommendation") =~ unquote(expected_text) refute element_exists?(html, "#super-admin-report") end end From e6bd40c21cfee214f6841592c7b864949ee5de7b Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Jul 2026 10:45:20 +0100 Subject: [PATCH 11/43] emails CTA banner --- .../js/dashboard/email-reports-cta-banner.tsx | 124 ++++++++++++++++++ assets/js/dashboard/index.tsx | 2 + .../first_dashboard_launch_banner.ex | 52 -------- .../controllers/site_controller.ex | 6 +- lib/plausible_web/live/installation.ex | 2 - .../templates/stats/stats.html.heex | 2 - .../controllers/site_controller_test.exs | 12 +- 7 files changed, 133 insertions(+), 67 deletions(-) create mode 100644 assets/js/dashboard/email-reports-cta-banner.tsx delete mode 100644 lib/plausible_web/components/first_dashboard_launch_banner.ex diff --git a/assets/js/dashboard/email-reports-cta-banner.tsx b/assets/js/dashboard/email-reports-cta-banner.tsx new file mode 100644 index 000000000000..8defbd306b23 --- /dev/null +++ b/assets/js/dashboard/email-reports-cta-banner.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useRef, useState } from 'react' +import { XMarkIcon } from '@heroicons/react/24/outline' +import { useSiteContext } from './site-context' +import { useCurrentVisitorsContext } from './current-visitors-context' + +type CTAStorageState = 'pending' | 'visible' + +function getStorageKey(domain: string) { + return `email_reports_cta_${domain}` +} + +// CTA for configuring weekly email reports +// +// Renders only once, as soon as the first pageview lands. This can happen: +// +// 1. Automatically, when the dashboard stays open -- relying on the value +// of current-visitors changing to something other than 0. +// +// 2. Dashboard is refreshed and showing data for the very first time. +// +// Case 2 is the tricky one. By the time of the refresh, `site.statsBegin` +// is already set, so that value alone can't distinguish "stats just +// started" from "this site has always had stats". +// +// The sessionStorage entry closes that gap -- it's stamped 'pending' the +// moment stats are still absent, so a later reload can still recognize the +// transition. It is only ever stamped while stats are absent, so established +// sites never pick it up and can't retrigger the CTA. +// +// Once shown, the same entry is stamped 'visible', so a refresh mid-display +// resumes the CTA instead of re-deciding from scratch -- but only for three +// seconds -- past that, the sessionStorage entry clears itself out and a +// refresh won't bring the CTA back. +export function EmailReportsCTABanner() { + const site = useSiteContext() + const currentVisitors = useCurrentVisitorsContext() + const hasStats = !!site.statsBegin + const storageKey = getStorageKey(site.domain) + + const hasTriggeredRef = useRef(false) + const [visible, setVisible] = useState(false) + + useEffect(() => { + if (!hasStats && sessionStorage.getItem(storageKey) !== 'visible') { + const state: CTAStorageState = 'pending' + sessionStorage.setItem(storageKey, state) + } + }, [hasStats, storageKey]) + + useEffect(() => { + if (hasTriggeredRef.current) { + return + } + + const storedState = sessionStorage.getItem(storageKey) + + if (storedState === 'visible') { + hasTriggeredRef.current = true + setVisible(true) + return + } + + const firstPageviewJustLanded = hasStats + ? storedState === 'pending' + : !!currentVisitors + + if (!firstPageviewJustLanded) { + return + } + + hasTriggeredRef.current = true + const state: CTAStorageState = 'visible' + sessionStorage.setItem(storageKey, state) + setVisible(true) + }, [hasStats, currentVisitors, storageKey]) + + useEffect(() => { + if (!visible) { + return + } + + const timeout = setTimeout(() => { + sessionStorage.removeItem(storageKey) + }, 3000) + + return () => clearTimeout(timeout) + }, [visible, storageKey]) + + if (!visible) { + return null + } + + function dismiss() { + sessionStorage.removeItem(storageKey) + setVisible(false) + } + + return ( + + ) +} diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index b4c8e0c1cb15..bb2f7ac6be1f 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -12,6 +12,7 @@ import { GraphIntervalProvider } from './stats/graph/graph-interval-context' import { ImportsIncludedProvider } from './stats/graph/imports-included-context' import { CurrentVisitorsProvider } from './current-visitors-context' import { VerificationLiveViewPortal } from './verification/portal' +import { EmailReportsCTABanner } from './email-reports-cta-banner' function DashboardStats({ importedDataInView, @@ -23,6 +24,7 @@ function DashboardStats({ return ( <>
    +
    diff --git a/lib/plausible_web/components/first_dashboard_launch_banner.ex b/lib/plausible_web/components/first_dashboard_launch_banner.ex deleted file mode 100644 index 7646368a6fdd..000000000000 --- a/lib/plausible_web/components/first_dashboard_launch_banner.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule PlausibleWeb.Components.FirstDashboardLaunchBanner do - @moduledoc """ - A banner that appears on the first dashboard launch - """ - - use PlausibleWeb, :component - - attr(:site, Plausible.Site, required: true) - - def set(assigns) do - ~H""" - - """ - end - - attr(:site, Plausible.Site, required: true) - - def render(assigns) do - ~H""" - - """ - end - - defp x_data(site) do - "{show: !!sessionStorage.getItem('#{storage_key(site)}')}" - end - - defp x_init(site) do - "setTimeout(() => sessionStorage.removeItem('#{storage_key(site)}'), 3000)" - end - - defp storage_key(site) do - "dashboard_seen_#{site.domain}" - end -end diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex index 7413111898eb..108891f486e1 100644 --- a/lib/plausible_web/controllers/site_controller.ex +++ b/lib/plausible_web/controllers/site_controller.ex @@ -50,11 +50,7 @@ defmodule PlausibleWeb.SiteController do end redirect(conn, - to: - Routes.site_path(conn, :installation, site.domain, - site_created: true, - flow: flow - ) + to: Routes.site_path(conn, :installation, site.domain, flow: flow) ) {:error, _, :permission_denied, _} -> diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index f88e6aaf1a79..769949868796 100644 --- a/lib/plausible_web/live/installation.ex +++ b/lib/plausible_web/live/installation.ex @@ -82,7 +82,6 @@ defmodule PlausibleWeb.Live.Installation do {:ok, assign(socket, site: site, - site_created?: params["site_created"] == "true", flow: flow )} end @@ -104,7 +103,6 @@ defmodule PlausibleWeb.Live.Installation do def render(assigns) do ~H"""
    - <.focus_box> diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index aceb45a07ff2..cbd6602bd48b 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -1,6 +1,4 @@
    - - <%= if Plausible.Teams.locked?(@site.team) do %>
    %Verification.Diagnostics{ - plausible_is_on_window: false, - plausible_is_initialized: false, - service_error: %{code: :domain_not_found} - }, - 4 => %Verification.Diagnostics{disallowed_by_csp: true} - } - @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t() def run(url, data_domain, installation_type, opts \\ []) do # Timeout option for testing purposes @@ -77,7 +59,6 @@ defmodule Plausible.InstallationSupport.Verification.Checks do opts \\ [] ) do telemetry? = Keyword.get(opts, :telemetry?, true) - {diagnostics, url} = debug_override(diagnostics, data_domain, url) result = Verification.Diagnostics.interpret( @@ -116,28 +97,4 @@ defmodule Plausible.InstallationSupport.Verification.Checks do result end - - # Also overrides `url` to a clean, query-string-free one - otherwise the - # real check pipeline's cache-busting query param (?plausible_verification=...) - # leaks into canned error messages like "We couldn't find your website at ...". - defp debug_override(diagnostics, data_domain, url) do - case Map.get(@debug_scenarios, @debug_scenario) do - nil -> - {diagnostics, url} - - :success -> - { - %Verification.Diagnostics{ - test_event: %{ - "normalizedBody" => %{"domain" => data_domain}, - "responseStatus" => 200 - } - }, - "https://#{data_domain}" - } - - %Verification.Diagnostics{} = debug -> - {debug, "https://#{data_domain}"} - end - end end diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index ff4742bf3a56..c0ea7b5ce793 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -8,7 +8,7 @@ defmodule PlausibleWeb.Live.Verification do alias Plausible.InstallationSupport.{State, Verification} @component PlausibleWeb.Live.Components.VerificationBanner - @slowdown_for_frequent_checking :timer.seconds(0) + @slowdown_for_frequent_checking :timer.seconds(5) @use_portal? Mix.env() not in [:test, :ce_test] def mount( From 2494403170e589ae8838d8e49a4e9842396db353 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Jul 2026 14:24:47 +0100 Subject: [PATCH 13/43] fix test after rebase --- .../installation_support/verification/checks_test.exs | 4 ++-- test/plausible_web/live/installation_test.exs | 2 -- test/plausible_web/live/verification_test.exs | 6 ++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/test/plausible/installation_support/verification/checks_test.exs b/test/plausible/installation_support/verification/checks_test.exs index f3541ae1900e..7b8118c9aad7 100644 --- a/test/plausible/installation_support/verification/checks_test.exs +++ b/test/plausible/installation_support/verification/checks_test.exs @@ -32,7 +32,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: - "Check that the URL is correct and publicly accessible. If your site is intentionally private, you can verify your installation manually", + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you'll need to verify your installation manually", inline_links: [^@verify_manually_inline_link] } ] @@ -58,7 +58,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: - "Check that the URL is correct and publicly accessible. If your site is intentionally private, you can verify your installation manually", + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you'll need to verify your installation manually", inline_links: [^@verify_manually_inline_link] } ] diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs index 763b6dfdfac9..8b199583ae72 100644 --- a/test/plausible_web/live/installation_test.exs +++ b/test/plausible_web/live/installation_test.exs @@ -253,8 +253,6 @@ defmodule PlausibleWeb.Live.InstallationTest do } do stub_dns() - stub_lookup_a_records(site.domain) - stub_detection_manual() {lv, _html} = get_lv(conn, site, "?type=#{unquote(type)}") diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 7ee23fcc3a03..664d4ce5c021 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -68,8 +68,6 @@ defmodule PlausibleWeb.Live.VerificationTest do } do stub_dns() - stub_lookup_a_records(site.domain) - stub_verification_result(%{ "completed" => true, "trackerIsInHtml" => false, @@ -141,7 +139,7 @@ defmodule PlausibleWeb.Live.VerificationTest do @tag :ee_only test "the dismissed flag keeps the banner hidden even if a late update arrives while still connected", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => true, @@ -186,7 +184,7 @@ defmodule PlausibleWeb.Live.VerificationTest do @tag :ee_only test "dismissing tells the client to close the websocket connection", %{conn: conn, site: site} do - stub_lookup_a_records(site.domain) + stub_dns() stub_verification_result(%{ "completed" => false, From c797cc0b383caedc2dfad840ad1e585c732c82e7 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Jul 2026 17:16:54 +0100 Subject: [PATCH 14/43] fix CE tests --- .../verification/diagnostics_test.exs | 69 ++++++++++--------- .../controllers/stats_controller_test.exs | 24 ++++--- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/test/plausible/installation_support/verification/diagnostics_test.exs b/test/plausible/installation_support/verification/diagnostics_test.exs index bead8dfa1df0..21a065654faa 100644 --- a/test/plausible/installation_support/verification/diagnostics_test.exs +++ b/test/plausible/installation_support/verification/diagnostics_test.exs @@ -1,45 +1,48 @@ defmodule Plausible.InstallationSupport.Verification.DiagnosticsTest do use ExUnit.Case, async: true + use Plausible - alias Plausible.InstallationSupport.Verification.Diagnostics.Error + on_ee do + alias Plausible.InstallationSupport.Verification.Diagnostics.Error - describe "Error.new!/1" do - test "accepts a recommendation whose inline_links text appears exactly once" do - assert %Error{} = - Error.new!(%{ - message: "Something went wrong", - recommendation: "Check the docs for more info", - inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] - }) - end + describe "Error.new!/1" do + test "accepts a recommendation whose inline_links text appears exactly once" do + assert %Error{} = + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs for more info", + inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] + }) + end - test "raises when inline_links text isn't found in the recommendation" do - assert_raise ArgumentError, ~r/must appear exactly once/, fn -> - Error.new!(%{ - message: "Something went wrong", - recommendation: "Check the manual for more info", - inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] - }) + test "raises when inline_links text isn't found in the recommendation" do + assert_raise ArgumentError, ~r/must appear exactly once/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the manual for more info", + inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] + }) + end end - end - test "raises when inline_links text appears more than once in the recommendation" do - assert_raise ArgumentError, ~r/must appear exactly once/, fn -> - Error.new!(%{ - message: "Something went wrong", - recommendation: "Check the docs, or check the docs again", - inline_links: [%{text: "the docs", href: "https://plausible.io/docs"}] - }) + test "raises when inline_links text appears more than once in the recommendation" do + assert_raise ArgumentError, ~r/must appear exactly once/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs, or check the docs again", + inline_links: [%{text: "the docs", href: "https://plausible.io/docs"}] + }) + end end - end - test "raises when inline_links href doesn't point at plausible.io" do - assert_raise ArgumentError, ~r/must start with/, fn -> - Error.new!(%{ - message: "Something went wrong", - recommendation: "Check the docs for more info", - inline_links: [%{text: "docs", href: "https://example.com/docs"}] - }) + test "raises when inline_links href doesn't point at plausible.io" do + assert_raise ArgumentError, ~r/must start with/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs for more info", + inline_links: [%{text: "docs", href: "https://example.com/docs"}] + }) + end end end end diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index fab0eef0ba75..77c4c7004c4b 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -147,18 +147,20 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" end - test "can view stats of a website I've created; verification banner only shows with the explicit param", - %{ - conn: conn, - site: site - } do - resp = get(conn, "/" <> site.domain) |> html_response(200) - assert text_of_attr(resp, @react_container, "data-logged-in") == "true" - refute resp =~ "Verifying your installation" + on_ee do + test "verification banner only shows with the explicit param", + %{ + conn: conn, + site: site + } do + resp = get(conn, "/" <> site.domain) |> html_response(200) + refute resp =~ "Verifying your installation" - resp = conn |> get("/" <> site.domain <> "?verify_installation=true") |> html_response(200) - assert text_of_attr(resp, @react_container, "data-logged-in") == "true" - assert resp =~ "Verifying your installation" + resp = + conn |> get("/" <> site.domain <> "?verify_installation=true") |> html_response(200) + + assert resp =~ "Verifying your installation" + end end on_ee do From f03da3a271ceb8439a2782b0939e308402f9b379 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Jul 2026 17:43:14 +0100 Subject: [PATCH 15/43] improve stats_controller_test.exs --- .../controllers/stats_controller_test.exs | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 77c4c7004c4b..dd0ac8d8601b 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -3,6 +3,7 @@ defmodule PlausibleWeb.StatsControllerTest do use Plausible.Repo @react_container "div#stats-react-container" + @verification_banner "#verification-ui" describe "GET /:domain - anonymous user" do test "public site - shows site stats", %{conn: conn} do @@ -112,7 +113,7 @@ defmodule PlausibleWeb.StatsControllerTest do resp = get(conn, "/some-other-public-site.io") |> html_response(200) - refute resp =~ "Verifying your installation" + refute element_exists?(resp, @verification_banner) end test "public site - anonymous visitors never see the verification banner, even with the param", @@ -125,7 +126,7 @@ defmodule PlausibleWeb.StatsControllerTest do get(conn, "/some-other-public-site.io?verify_installation=true") |> html_response(200) assert text_of_attr(resp, @react_container, "data-logged-in") == "false" - refute resp =~ "Verifying your installation" + refute element_exists?(resp, @verification_banner) end test "can not view stats of a private website", %{conn: conn} do @@ -153,13 +154,12 @@ defmodule PlausibleWeb.StatsControllerTest do conn: conn, site: site } do - resp = get(conn, "/" <> site.domain) |> html_response(200) - refute resp =~ "Verifying your installation" + resp = get(conn, "/#{site.domain}") |> html_response(200) + refute element_exists?(resp, @verification_banner) - resp = - conn |> get("/" <> site.domain <> "?verify_installation=true") |> html_response(200) + resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) - assert resp =~ "Verifying your installation" + assert element_exists?(resp, @verification_banner) end end @@ -234,21 +234,22 @@ defmodule PlausibleWeb.StatsControllerTest do assert cv.native_stats_start_at == twenty_days_ago end - test "does not redirect consolidated views to verification", %{ - conn: conn, - user: user - } do + test "does not show verification banner for consolidated views even with the explicit param", + %{ + conn: conn, + user: user + } do new_site(owner: user) new_site(owner: user) cv = user |> team_of() |> new_consolidated_view() - conn = get(conn, "/" <> cv.domain) - resp = html_response(conn, 200) + resp = get(conn, "/#{cv.domain}?verify_installation=true") |> html_response(200) assert text_of_attr(resp, @react_container, "data-domain") == cv.domain assert text_of_attr(resp, @react_container, "data-logged-in") == "true" assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" + refute element_exists?(resp, @verification_banner) end test "redirects to /sites if for some reason ineligible anymore", %{ @@ -392,11 +393,15 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" end - test "can enter verification when site is without stats", %{conn: conn} do - site = new_site() + test "can enter verification regardless of whether the site has stats or not", %{conn: conn} do + site_without_stats = new_site() + site_with_stats = new_site() + populate_stats(site_with_stats, [build(:pageview)]) - resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) - assert resp =~ "Verifying your installation" + for site <- [site_without_stats, site_with_stats] do + resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + assert element_exists?(resp, @verification_banner) + end end test "can view a private locked dashboard with stats", %{conn: conn} do @@ -410,13 +415,19 @@ defmodule PlausibleWeb.StatsControllerTest do assert resp =~ "This dashboard is actually locked" end - test "can view private locked verification without stats", %{conn: conn} do - user = new_user() - site = new_site(owner: user) - site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + test "can trigger verification on a locked private dashboard regardless of whether the site has stats or not", + %{conn: conn} do + site_without_stats = new_site(owner: new_user()) + site_without_stats.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() - resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) - assert resp =~ "Verifying your installation" + site_with_stats = new_site(owner: new_user()) + populate_stats(site_with_stats, [build(:pageview)]) + site_with_stats.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + for site <- [site_without_stats, site_with_stats] do + resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + assert element_exists?(resp, @verification_banner) + end end test "can view a locked public dashboard", %{conn: conn} do From f11d0c42c56d0d38ec689e3c2a560afef9c26b22 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Jul 2026 18:39:50 +0100 Subject: [PATCH 16/43] E2E: named verification results + add genserver for mock scenarios --- .../verification/diagnostics.ex | 113 ++++++++++++++---- .../verification/mock_scenarios.ex | 51 ++++++++ lib/plausible/application.ex | 5 + .../verification/mock_scenarios_test.exs | 72 +++++++++++ 4 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 extra/lib/plausible/installation_support/verification/mock_scenarios.ex create mode 100644 test/plausible/installation_support/verification/mock_scenarios_test.exs diff --git a/extra/lib/plausible/installation_support/verification/diagnostics.ex b/extra/lib/plausible/installation_support/verification/diagnostics.ex index d464a0155fda..2e6eb1872404 100644 --- a/extra/lib/plausible/installation_support/verification/diagnostics.ex +++ b/extra/lib/plausible/installation_support/verification/diagnostics.ex @@ -102,7 +102,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when response_status in [200, 202] and domain == expected_domain, - do: handled_error(@error_succeeds_only_after_cache_bust) + do: named_result!(:succeeds_only_after_cache_bust) def interpret( %__MODULE__{ @@ -119,7 +119,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when response_status in [200, 202] and domain == expected_domain, - do: success() + do: named_result!(:success) def interpret( %__MODULE__{ @@ -137,8 +137,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when response_status in [200, 202] and domain != expected_domain do - error_unexpected_domain(selected_installation_type) - |> handled_error() + named_result!(:unexpected_domain, installation_type: selected_installation_type) end @error_proxy_network_error Error.new!(%{ @@ -174,9 +173,9 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do proxying? = not String.starts_with?(request_url, PlausibleWeb.Endpoint.url()) if proxying? do - handled_error(@error_proxy_network_error) + named_result!(:proxy_network_error) else - handled_error(@error_plausible_network_error) + named_result!(:plausible_network_error) end end @@ -193,8 +192,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when plausible_is_on_window != true and plausible_is_initialized != true do - error_plausible_not_found("manual") - |> handled_error() + named_result!(:plausible_not_found, installation_type: "manual") end @error_csp_disallowed Error.new!(%{ @@ -218,7 +216,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do _expected_domain, _url ), - do: handled_error(@error_csp_disallowed) + do: named_result!(:csp_disallowed) @error_domain_not_found Error.new!(%{ message: "We couldn't reach <%= @attempted_url %>", @@ -231,9 +229,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do when code in [:domain_not_found, :invalid_url] do attempted_url = if url, do: url, else: "https://#{expected_domain}" - @error_domain_not_found - |> handled_error(attempted_url: attempted_url) - |> struct!(data: %{offer_custom_url_input: true}) + named_result!(:domain_not_found, attempted_url: attempted_url) end @error_browserless_network Error.new!(%{ @@ -251,9 +247,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do when is_binary(url) do attempted_url = shorten_url(url) - @error_browserless_network - |> handled_error(attempted_url: attempted_url) - |> struct!(data: %{offer_custom_url_input: true}) + named_result!(:browserless_network_error, attempted_url: attempted_url) end @error_browserless_temporary Error.new!(%{ @@ -265,7 +259,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do def interpret(%__MODULE__{service_error: %{code: code}}, _expected_domain, _url) when code in [:bad_browserless_response, :browserless_timeout, :internal_check_timeout] do - unhandled_error(@error_browserless_temporary, browserless_issue: true) + named_result!(:browserless_temporary) end @error_unexpected_page_response Error.new!(%{ @@ -290,9 +284,10 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do plausible_is_initialized != true do attempted_url = shorten_url(url) - @error_unexpected_page_response - |> handled_error(attempted_url: attempted_url, page_response_status: page_response_status) - |> struct!(data: %{offer_custom_url_input: true}) + named_result!(:unexpected_page_response, + attempted_url: attempted_url, + page_response_status: page_response_status + ) end def interpret( @@ -304,13 +299,13 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do _expected_domain, _url ) do - error_plausible_not_found(selected_installation_type) - |> handled_error() + named_result!(:plausible_not_found, installation_type: selected_installation_type) end def interpret(%__MODULE__{} = diagnostics, _expected_domain, _url) do - error_plausible_not_found(diagnostics.selected_installation_type) - |> unhandled_error() + named_result!(:plausible_not_found_unhandled, + installation_type: diagnostics.selected_installation_type + ) end @message_plausible_not_found "We couldn't detect Plausible on your site" @@ -413,4 +408,76 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do recommendations: [%{text: error.recommendation, inline_links: error.inline_links}] } end + + @doc """ + Looks up a named interpretation result, optionally built from the given + assigns (e.g. `attempted_url`, `installation_type`) - keys that don't need + any just ignore them. + + Every result `interpret/3` can produce is named here, so that verification + can be mocked (see `Plausible.InstallationSupport.Verification.ChecksMock`) + by referring to the exact same result-construction code `interpret/3` + itself uses - a scenario name can never silently drift from what real + verification would have interpreted. + """ + @spec named_result!(atom()) :: Result.t() + def named_result!(key), do: named_result!(key, []) + + @spec named_result!(atom(), Keyword.t()) :: Result.t() + def named_result!(:success, _assigns), do: success() + + def named_result!(:succeeds_only_after_cache_bust, _assigns), + do: handled_error(@error_succeeds_only_after_cache_bust) + + def named_result!(:csp_disallowed, _assigns), do: handled_error(@error_csp_disallowed) + def named_result!(:proxy_network_error, _assigns), do: handled_error(@error_proxy_network_error) + + def named_result!(:plausible_network_error, _assigns), + do: handled_error(@error_plausible_network_error) + + def named_result!(:browserless_temporary, _assigns), + do: unhandled_error(@error_browserless_temporary, browserless_issue: true) + + def named_result!(:unexpected_domain, assigns) do + Keyword.fetch!(assigns, :installation_type) + |> error_unexpected_domain() + |> handled_error() + end + + def named_result!(:plausible_not_found, assigns) do + Keyword.fetch!(assigns, :installation_type) + |> error_plausible_not_found() + |> handled_error() + end + + def named_result!(:plausible_not_found_unhandled, assigns) do + Keyword.fetch!(assigns, :installation_type) + |> error_plausible_not_found() + |> unhandled_error() + end + + def named_result!(:domain_not_found, assigns) do + @error_domain_not_found + |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) + |> struct!(data: %{offer_custom_url_input: true}) + end + + def named_result!(:browserless_network_error, assigns) do + @error_browserless_network + |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) + |> struct!(data: %{offer_custom_url_input: true}) + end + + def named_result!(:unexpected_page_response, assigns) do + @error_unexpected_page_response + |> handled_error( + attempted_url: Keyword.fetch!(assigns, :attempted_url), + page_response_status: Keyword.fetch!(assigns, :page_response_status) + ) + |> struct!(data: %{offer_custom_url_input: true}) + end + + def named_result!(key, _assigns) do + raise ArgumentError, "No interpretation result named #{inspect(key)}" + end end diff --git a/extra/lib/plausible/installation_support/verification/mock_scenarios.ex b/extra/lib/plausible/installation_support/verification/mock_scenarios.ex new file mode 100644 index 000000000000..1c648cf03d28 --- /dev/null +++ b/extra/lib/plausible/installation_support/verification/mock_scenarios.ex @@ -0,0 +1,51 @@ +defmodule Plausible.InstallationSupport.Verification.MockScenarios do + @moduledoc """ + Per-domain registry of forced verification outcomes. + + Used to bypass the real DNS lookup and browserless check when iterating + on `PlausibleWeb.Live.Verification`'s banner UI locally, or when driving + it from Playwright e2e specs. + """ + + use GenServer + + @type scenario :: %{interpretation_result: atom(), slowdown: non_neg_integer() | nil} + + def start_link(_opts) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + @doc """ + Registers a mock verification for `domain`. + + The `key` must be an atom that's recognized by + `Plausible.InstallationSupport.Verification.Diagnostics.named_result!/2`. + + ### Opts + + * `:slowdown` - overrides the check pipeline's default per-check delay + """ + @spec put(String.t(), atom(), Keyword.t()) :: :ok + def put(domain, key, opts \\ []) when is_binary(domain) and is_atom(key) do + scenario = %{interpretation_result: key, slowdown: Keyword.get(opts, :slowdown)} + GenServer.call(__MODULE__, {:put, domain, scenario}) + end + + @doc "Returns the scenario registered for `domain`, or `nil` if none was set." + @spec get(String.t()) :: scenario() | nil + def get(domain) when is_binary(domain) do + GenServer.call(__MODULE__, {:get, domain}) + end + + @impl true + def init(state), do: {:ok, state} + + @impl true + def handle_call({:put, domain, scenario}, _from, state) do + {:reply, :ok, Map.put(state, domain, scenario)} + end + + def handle_call({:get, domain}, _from, state) do + {:reply, Map.get(state, domain), state} + end +end diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 20191fc70189..86de147f3b6a 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -194,6 +194,11 @@ defmodule Plausible.Application do end, Plausible.Ingestion.Counters, Plausible.Session.Salts, + on_ee do + if Mix.env() in [:dev, :e2e_test, :test] do + Plausible.InstallationSupport.Verification.MockScenarios + end + end, Supervisor.child_spec(Plausible.Event.WriteBuffer, id: Plausible.Event.WriteBuffer), Supervisor.child_spec(Plausible.Session.WriteBuffer, id: Plausible.Session.WriteBuffer), ReferrerBlocklist, diff --git a/test/plausible/installation_support/verification/mock_scenarios_test.exs b/test/plausible/installation_support/verification/mock_scenarios_test.exs new file mode 100644 index 000000000000..bcf7f2d93a3a --- /dev/null +++ b/test/plausible/installation_support/verification/mock_scenarios_test.exs @@ -0,0 +1,72 @@ +defmodule Plausible.InstallationSupport.Verification.MockScenariosTest do + use Plausible.DataCase, async: true + + on_ee do + alias Plausible.InstallationSupport.Verification.MockScenarios + + test "get/1 returns nil for a domain with no registered scenario" do + site = insert(:site) + + assert MockScenarios.get(site.domain) == nil + end + + test "put/3 registers a scenario, get/1 returns it" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :success, []) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :success, + slowdown: nil + } + end + + test "put/3 stores a slowdown opt alongside the interpretation result" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :domain_not_found, slowdown: 2000) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: 2000 + } + end + + test "put/3 overwrites a previously registered scenario for the same domain" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :success, []) + :ok = MockScenarios.put(site.domain, :csp_disallowed, []) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :csp_disallowed, + slowdown: nil + } + end + + test "scenarios registered for one domain never leak into another domain on the same registry" do + site_a = insert(:site) + site_b = insert(:site) + + :ok = MockScenarios.put(site_a.domain, :success, []) + :ok = MockScenarios.put(site_b.domain, :domain_not_found, slowdown: 500) + + assert MockScenarios.get(site_a.domain) == %{ + interpretation_result: :success, + slowdown: nil + } + + assert MockScenarios.get(site_b.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: 500 + } + + :ok = MockScenarios.put(site_a.domain, :csp_disallowed, []) + + assert MockScenarios.get(site_b.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: 500 + } + end + end +end From d2d969c2f6b634d3a884b9334c0a3060390940cb Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Jul 2026 20:09:41 +0100 Subject: [PATCH 17/43] E2E: checks_mock module --- .../verification/checks_mock.ex | 121 ++++++++++++++++++ .../verification/checks_mock_test.exs | 115 +++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 extra/lib/plausible/installation_support/verification/checks_mock.ex create mode 100644 test/plausible/installation_support/verification/checks_mock_test.exs diff --git a/extra/lib/plausible/installation_support/verification/checks_mock.ex b/extra/lib/plausible/installation_support/verification/checks_mock.ex new file mode 100644 index 000000000000..bcec4ec67550 --- /dev/null +++ b/extra/lib/plausible/installation_support/verification/checks_mock.ex @@ -0,0 +1,121 @@ +defmodule Plausible.InstallationSupport.Verification.ChecksMock do + @moduledoc """ + Drop-in replacement for `Plausible.InstallationSupport.Verification.Checks` + that never performs a real DNS lookup or browserless check for a domain + with a registered mock scenario. Used locally (`:dev`) and in Playwright + e2e specs (`:e2e_test`) to deterministically drive + `PlausibleWeb.Live.Verification`'s banner UI - see + `Plausible.InstallationSupport.verification_checks_mod/0`. + + When no scenario is registered for a domain (see + `Plausible.InstallationSupport.Verification.MockScenarios.put/3`): + + * in `:dev`, falls back to the real `Checks` module - casually loading a + site with `?verify_installation=true` still verifies for real unless + you've deliberately mocked that domain. + + * everywhere else (`:e2e_test`, and `:test` for this module's own + tests), raises - every e2e spec that drives verification is expected + to register a scenario before triggering it, and it shouldn't + silently fall back to a real, slow, non-deterministic check. + """ + + alias Plausible.InstallationSupport.{State, CheckRunner, Checks} + alias Plausible.InstallationSupport.Verification.{Diagnostics, MockScenarios} + alias Plausible.InstallationSupport.Verification.Checks, as: RealChecks + + defmodule FakeUrlCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.Url.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + defmodule FakeVerifyInstallationCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.VerifyInstallation.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + defmodule FakeVerifyInstallationCacheBustCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.VerifyInstallationCacheBust.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t() + def run(url, data_domain, installation_type, opts \\ []) do + case MockScenarios.get(data_domain) do + nil -> + raise_unless_dev_env!(data_domain) + RealChecks.run(url, data_domain, installation_type, opts) + + scenario -> + run_mocked(url, data_domain, installation_type, opts, scenario) + end + end + + defp run_mocked(url, data_domain, installation_type, opts, scenario) do + report_to = Keyword.get(opts, :report_to, self()) + async? = Keyword.get(opts, :async?, true) + slowdown = scenario.slowdown || Keyword.get(opts, :slowdown, 500) + + init_state = %State{ + url: url || "https://#{data_domain}", + data_domain: data_domain, + report_to: report_to, + diagnostics: %Diagnostics{selected_installation_type: installation_type} + } + + checks = [ + {FakeUrlCheck, []}, + {FakeVerifyInstallationCheck, []}, + {FakeVerifyInstallationCacheBustCheck, []} + ] + + CheckRunner.run(init_state, checks, + async?: async?, + report_to: report_to, + slowdown: slowdown + ) + end + + @spec interpret_diagnostics(State.t()) :: Plausible.InstallationSupport.Result.t() + def interpret_diagnostics(%State{data_domain: data_domain} = state) do + case MockScenarios.get(data_domain) do + nil -> + raise_unless_dev_env!(data_domain) + RealChecks.interpret_diagnostics(state) + + scenario -> + Diagnostics.named_result!(scenario.interpretation_result, + installation_type: state.diagnostics.selected_installation_type, + attempted_url: state.url, + page_response_status: 500 + ) + end + end + + defp raise_unless_dev_env!(data_domain) do + if Mix.env() != :dev do + raise """ + ChecksMock was used to verify #{inspect(data_domain)}, but no scenario \ + is registered for it. Call MockScenarios.put/3 first. + """ + end + end +end diff --git a/test/plausible/installation_support/verification/checks_mock_test.exs b/test/plausible/installation_support/verification/checks_mock_test.exs new file mode 100644 index 000000000000..979d4ae91942 --- /dev/null +++ b/test/plausible/installation_support/verification/checks_mock_test.exs @@ -0,0 +1,115 @@ +defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do + use Plausible.DataCase, async: true + + on_ee do + alias Plausible.InstallationSupport.{Checks, Result} + alias Plausible.InstallationSupport.Verification.{ChecksMock, Diagnostics, MockScenarios} + + @url "https://example.com" + + describe "run/4" do + test "raises when no scenario is registered for the domain" do + domain = insert(:site).domain + + assert_raise RuntimeError, ~r/no scenario is registered/, fn -> + ChecksMock.run(@url, domain, "manual", async?: false, slowdown: 0, report_to: nil) + end + end + + test "runs synchronously, keeping the given installation_type in the resulting state" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + state = + ChecksMock.run(@url, domain, "wordpress", async?: false, slowdown: 0, report_to: nil) + + assert state.url == @url + assert state.data_domain == domain + assert state.diagnostics.selected_installation_type == "wordpress" + end + + test "notifies check_start for all 3 checks with the same messages as real verification, then all_checks_done" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + ChecksMock.run(@url, domain, "manual", async?: false, slowdown: 0, report_to: self()) + + assert_received {:check_start, {ChecksMock.FakeUrlCheck, _state}} + assert_received {:check_start, {ChecksMock.FakeVerifyInstallationCheck, _state}} + assert_received {:check_start, {ChecksMock.FakeVerifyInstallationCacheBustCheck, _state}} + assert_received {:all_checks_done, %{data_domain: ^domain}} + + assert ChecksMock.FakeUrlCheck.report_progress_as() == + Checks.Url.report_progress_as() + + assert ChecksMock.FakeVerifyInstallationCheck.report_progress_as() == + Checks.VerifyInstallation.report_progress_as() + + assert ChecksMock.FakeVerifyInstallationCacheBustCheck.report_progress_as() == + Checks.VerifyInstallationCacheBust.report_progress_as() + end + + test "defaults state.url from data_domain when called with url: nil, mirroring the real Url check" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + state = ChecksMock.run(nil, domain, "manual", async?: false, slowdown: 0, report_to: nil) + + assert state.url == "https://#{domain}" + end + end + + describe "interpret_diagnostics/1" do + test "returns the named result for the registered scenario" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + state = ChecksMock.run(@url, domain, "manual", async?: false, slowdown: 0, report_to: nil) + + assert %Result{ok?: true} = ChecksMock.interpret_diagnostics(state) + end + + test "returns interpretation based on installation type" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :plausible_not_found) + + state = + ChecksMock.run(@url, domain, "wordpress", async?: false, slowdown: 0, report_to: nil) + + assert %Result{ + ok?: false, + recommendations: [%{text: recommendation}] + } = ChecksMock.interpret_diagnostics(state) + + assert recommendation =~ "WordPress plugin" + end + + test "uses state.url (e.g. a custom retry URL) as attempted_url, not just the bare domain" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :domain_not_found) + + custom_url = "https://abc.de" + + state = + ChecksMock.run(custom_url, domain, "manual", async?: false, slowdown: 0, report_to: nil) + + assert %Result{errors: [error]} = ChecksMock.interpret_diagnostics(state) + assert error =~ custom_url + end + + test "raises when no scenario is registered for the domain" do + domain = insert(:site).domain + + state = %Plausible.InstallationSupport.State{ + url: @url, + data_domain: domain, + diagnostics: %Diagnostics{selected_installation_type: "manual"} + } + + assert_raise RuntimeError, ~r/no scenario is registered/, fn -> + ChecksMock.interpret_diagnostics(state) + end + end + end + end +end From 6dc97b720b6b0e6cb33744f4002a51f2be748760 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 08:01:41 +0100 Subject: [PATCH 18/43] E2E: plug in the ChecksMock module --- .../installation_support/installation_support.ex | 8 ++++++++ extra/lib/plausible_web/live/verification.ex | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/extra/lib/plausible/installation_support/installation_support.ex b/extra/lib/plausible/installation_support/installation_support.ex index f724a96d28ea..c1320a62aa4a 100644 --- a/extra/lib/plausible/installation_support/installation_support.ex +++ b/extra/lib/plausible/installation_support/installation_support.ex @@ -12,6 +12,14 @@ defmodule Plausible.InstallationSupport do def user_agent() do "Plausible Verification Agent - if abused, contact support@plausible.io" end + + def verification_checks_mod do + if Mix.env() in [:dev, :e2e_test] do + Plausible.InstallationSupport.Verification.ChecksMock + else + Plausible.InstallationSupport.Verification.Checks + end + end else def user_agent() do "Plausible Community Edition" diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index c0ea7b5ce793..1d9550a6b5da 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -5,7 +5,8 @@ defmodule PlausibleWeb.Live.Verification do """ use PlausibleWeb, :live_view - alias Plausible.InstallationSupport.{State, Verification} + alias Plausible.InstallationSupport + alias Plausible.InstallationSupport.State @component PlausibleWeb.Live.Components.VerificationBanner @slowdown_for_frequent_checking :timer.seconds(5) @@ -143,7 +144,7 @@ defmodule PlausibleWeb.Live.Verification do end {:ok, pid} = - Verification.Checks.run( + InstallationSupport.verification_checks_mod().run( socket.assigns.url_to_verify, domain, get_installation_type(socket.assigns.tracker_script_configuration), @@ -171,7 +172,7 @@ defmodule PlausibleWeb.Live.Verification do end def handle_info({:all_checks_done, %State{} = state}, socket) do - interpretation = Verification.Checks.interpret_diagnostics(state) + interpretation = InstallationSupport.verification_checks_mod().interpret_diagnostics(state) update_component(socket, finished?: true, From f67e7df746ebf1f4eda39f473af318a4ee441b0a Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 08:27:34 +0100 Subject: [PATCH 19/43] E2E: put_verification_scenario endpoint --- e2e/tests/fixtures.ts | 22 +++++++++++++++++++ lib/plausible_web/router.ex | 1 + .../support/dev/controllers/e2e_controller.ex | 10 +++++++++ 3 files changed, 33 insertions(+) diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts index 55bb6a99f2c8..2d4c03d9b226 100644 --- a/e2e/tests/fixtures.ts +++ b/e2e/tests/fixtures.ts @@ -241,6 +241,28 @@ export async function populateStats({ expect(response.ok()).toBeTruthy() } +export async function setVerificationScenario({ + request, + domain, + scenario, + options +}: { + request: APIRequestContext + domain: string + scenario: string + options?: { slowdown?: number } +}) { + const response = await request.put('/e2e-tests/verification', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + data: { domain: domain, scenario: scenario, options: options } + }) + + expect(response.ok()).toBeTruthy() +} + export async function addGoal({ request, domain, diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index b88211a93809..e867512f66e4 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -176,6 +176,7 @@ defmodule PlausibleWeb.Router do post "/stats", E2EController, :populate_stats post "/funnel", E2EController, :create_funnel post "/goal", E2EController, :create_goal + put "/verification", E2EController, :put_verification_scenario end end end diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex index bd61fdfbf2e1..fa1ed838de98 100644 --- a/test/support/dev/controllers/e2e_controller.ex +++ b/test/support/dev/controllers/e2e_controller.ex @@ -96,6 +96,16 @@ defmodule PlausibleWeb.E2EController do send_resp(conn, 200, Jason.encode!(%{"ok" => true})) end + def put_verification_scenario(conn, %{"domain" => domain, "scenario" => scenario} = params) do + key = String.to_existing_atom(scenario) + + opts = [slowdown: params["options"]["slowdown"] || 0] + + :ok = Plausible.InstallationSupport.Verification.MockScenarios.put(domain, key, opts) + + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + end + defp get_goal(site, name) do Plausible.Repo.get_by!(Plausible.Goal, site_id: site.id, display_name: name) end From b52e667c5c08a0dd8a2d214e215a5573107b3332 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 09:08:47 +0100 Subject: [PATCH 20/43] e2e test for verification success --- e2e/tests/dashboard/verification.spec.ts | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 e2e/tests/dashboard/verification.spec.ts diff --git a/e2e/tests/dashboard/verification.spec.ts b/e2e/tests/dashboard/verification.spec.ts new file mode 100644 index 000000000000..00a7168b24cb --- /dev/null +++ b/e2e/tests/dashboard/verification.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test' +import { setupSite, setVerificationScenario } from '../fixtures' +import { expectLiveViewConnected } from '../test-utils' + +const VERIFICATION_BANNER_SELECTOR = '#verification-ui' +const PROGRESS_MSG_SELECTOR = '#progress' + +const SUCCESS_MESSAGE = 'Tracking is active on your site' +const LOADING_STATE_TITLE = 'Verifying your installation' +const LOADING_STATE_CYCLED_MESSAGES = [ + /We're visiting your site to ensure that everything is working/, + /We're trying to reach your website/, + /We're verifying that your visitors are being counted correctly/ +] + +test('verification success', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await setVerificationScenario({ + request, + domain, + scenario: 'success', + options: { slowdown: 500 } + }) + + await page.goto(`/${domain}?verify_installation=true`, { waitUntil: 'commit' }) + await expectLiveViewConnected(page) + + const banner = page.locator(VERIFICATION_BANNER_SELECTOR) + const progress = banner.locator(PROGRESS_MSG_SELECTOR) + + await expect(banner).toContainText(LOADING_STATE_TITLE) + + for (const msg of LOADING_STATE_CYCLED_MESSAGES) { + await expect(progress).toHaveText(msg) + } + + await expect(banner).toContainText(SUCCESS_MESSAGE) + + await banner.getByRole('button', { name: 'Dismiss' }).click() + + await expect(banner).toBeHidden() + await expect(page).not.toHaveURL(/verify_installation/) + + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toBeHidden() +}) From 3836bd88842d2e883d829a102ccff1aea7cd1f88 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 09:21:48 +0100 Subject: [PATCH 21/43] get CI green --- assets/js/dashboard/email-reports-cta-banner.tsx | 4 ++-- .../live/components/verification_banner_test.exs | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/assets/js/dashboard/email-reports-cta-banner.tsx b/assets/js/dashboard/email-reports-cta-banner.tsx index 8defbd306b23..6a69fb9d0c39 100644 --- a/assets/js/dashboard/email-reports-cta-banner.tsx +++ b/assets/js/dashboard/email-reports-cta-banner.tsx @@ -21,12 +21,12 @@ function getStorageKey(domain: string) { // Case 2 is the tricky one. By the time of the refresh, `site.statsBegin` // is already set, so that value alone can't distinguish "stats just // started" from "this site has always had stats". -// +// // The sessionStorage entry closes that gap -- it's stamped 'pending' the // moment stats are still absent, so a later reload can still recognize the // transition. It is only ever stamped while stats are absent, so established // sites never pick it up and can't retrigger the CTA. -// +// // Once shown, the same entry is stamped 'visible', so a refresh mid-display // resumes the CTA instead of re-deciding from scratch -- but only for three // seconds -- past that, the sessionStorage entry clears itself out and a diff --git a/test/plausible_web/live/components/verification_banner_test.exs b/test/plausible_web/live/components/verification_banner_test.exs index 0ecdf1bc8a58..22d638c9d794 100644 --- a/test/plausible_web/live/components/verification_banner_test.exs +++ b/test/plausible_web/live/components/verification_banner_test.exs @@ -9,10 +9,11 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do @moduletag :capture_log @component PlausibleWeb.Live.Components.VerificationBanner + @banner "#verification-ui" @progress ~s|#verification-ui p#progress| - @loading_spinner ~s|#verification-ui svg.animate-spin| - @check_circle ~s|#verification-ui #check-circle| + @loading_spinner ~s|#{@banner} svg.animate-spin| + @check_circle ~s|#{@banner} #check-circle| @recommendations ~s|#recommendation| @super_admin_report ~s|#super-admin-report| @@ -34,7 +35,7 @@ defmodule PlausibleWeb.Live.Components.VerificationBannerTest do refute element_exists?(html, @loading_spinner) refute element_exists?(html, @check_circle) refute element_exists?(html, @recommendations) - assert html =~ "We couldn't verify your installation" + assert text_of_element(html, @banner) =~ "We couldn't verify your installation" end test "renders diagnostic interpretation with inline verify link and standalone review-installation sentence" do From eb517fd33fd65b0aa1405350e846a72e233cfdad Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 12:11:56 +0100 Subject: [PATCH 22/43] migration: add onboarding_status --- .../20260727120000_add_onboarding_status_to_sites.exs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 priv/repo/migrations/20260727120000_add_onboarding_status_to_sites.exs diff --git a/priv/repo/migrations/20260727120000_add_onboarding_status_to_sites.exs b/priv/repo/migrations/20260727120000_add_onboarding_status_to_sites.exs new file mode 100644 index 000000000000..c2421fdbc363 --- /dev/null +++ b/priv/repo/migrations/20260727120000_add_onboarding_status_to_sites.exs @@ -0,0 +1,9 @@ +defmodule Plausible.Repo.Migrations.AddOnboardingStatusToSites do + use Ecto.Migration + + def change do + alter table(:sites) do + add :onboarding_status, :string, null: false, default: "completed" + end + end +end From 530ffd21b118d9f51135fa0711a8835d6cb9fb09 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 13:02:03 +0100 Subject: [PATCH 23/43] update site schema with the new field --- lib/plausible/site.ex | 47 +++++++++++++++++- test/plausible/site/schema_test.exs | 77 +++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index f7c76f589bad..b4c5aaa2daca 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -10,6 +10,8 @@ defmodule Plausible.Site do @type t() :: %__MODULE__{} + @onboarding_statuses [:new_site, :verification_succeeded, :first_pageview, :completed] + @derive {Jason.Encoder, only: [:domain, :timezone]} schema "sites" do field :domain, :string @@ -17,6 +19,7 @@ defmodule Plausible.Site do field :public, :boolean field :stats_start_date, :date field :native_stats_start_at, :naive_datetime + field :onboarding_status, Ecto.Enum, values: @onboarding_statuses, default: :completed field :allowed_event_props, {:array, :string} field :conversions_enabled, :boolean, default: true field :props_enabled, :boolean, default: true @@ -78,7 +81,20 @@ defmodule Plausible.Site do |> put_assoc(:team, team) end - def new(params), do: changeset(%__MODULE__{}, params) + def new(params) do + changeset(%__MODULE__{}, params) + |> put_change(:onboarding_status, initial_onboarding_status(params)) + end + + # Consolidated sites never receive direct ingestion or run per-site + # verification, so they'd otherwise get stuck at :new_site forever. + defp initial_onboarding_status(params) do + if Map.get(params, :consolidated) || Map.get(params, "consolidated") do + :completed + else + :new_site + end + end on_ee do @domain_unique_error """ @@ -135,7 +151,8 @@ defmodule Plausible.Site do :public, :native_stats_start_at, :ingest_rate_limit_threshold, - :ingest_rate_limit_scale_seconds + :ingest_rate_limit_scale_seconds, + :onboarding_status ]) |> validate_required([:timezone, :public]) |> validate_number(:ingest_rate_limit_scale_seconds, @@ -178,6 +195,32 @@ defmodule Plausible.Site do change(site, native_stats_start_at: val) end + def onboarding_statuses, do: @onboarding_statuses + + @doc """ + Advances `onboarding_status` to `new_status`, unless the site is already at + or past that point in the `onboarding_statuses/0` progression - onboarding + status only ever moves forward, though a transition can skip an + intermediate value. Composable with other changes to the same changeset + (accepts either a site or an existing changeset). + """ + def put_onboarding_status_advance(site_or_changeset, new_status) do + current_status = + case site_or_changeset do + %Ecto.Changeset{} = changeset -> changeset.data.onboarding_status + %__MODULE__{} = site -> site.onboarding_status + end + + new_index = Enum.find_index(@onboarding_statuses, &(&1 == new_status)) + current_index = Enum.find_index(@onboarding_statuses, &(&1 == current_status)) + + if new_index > current_index do + change(site_or_changeset, onboarding_status: new_status) + else + change(site_or_changeset) + end + end + defp clean_domain(changeset) do clean_domain = (get_field(changeset, :domain) || "") diff --git a/test/plausible/site/schema_test.exs b/test/plausible/site/schema_test.exs index 91a19761f268..aa1cf24212a2 100644 --- a/test/plausible/site/schema_test.exs +++ b/test/plausible/site/schema_test.exs @@ -4,6 +4,25 @@ defmodule Plausible.SiteTest do doctest Plausible.Site + describe "new/1" do + test "sets onboarding_status to :new_site by default" do + changeset = Site.new(%{"domain" => "example.com", "timezone" => "Europe/London"}) + + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :new_site + end + + test "sets onboarding_status to :completed for a consolidated site" do + changeset = + Site.new(%{ + "domain" => "example.com", + "timezone" => "Europe/London", + "consolidated" => true + }) + + assert Ecto.Changeset.apply_changes(changeset).onboarding_status == :completed + end + end + describe "tz_offset/2" do test "returns offset from utc in seconds" do site = build(:site, timezone: "US/Eastern") @@ -31,4 +50,62 @@ defmodule Plausible.SiteTest do assert Site.tz_offset(site, ~U[2023-11-05 06:00:00Z]) == -18_000 end end + + describe "put_onboarding_status_advance/2" do + test "advances onboarding_status forward" do + site = insert(:site, onboarding_status: :new_site) + + changeset = Site.put_onboarding_status_advance(site, :verification_succeeded) + + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :verification_succeeded + end + + test "can skip an intermediate status" do + site = insert(:site, onboarding_status: :new_site) + + changeset = Site.put_onboarding_status_advance(site, :first_pageview) + + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :first_pageview + end + + test "is a no-op when the site is already at the given status" do + site = insert(:site, onboarding_status: :verification_succeeded) + + changeset = Site.put_onboarding_status_advance(site, :verification_succeeded) + + refute Ecto.Changeset.get_change(changeset, :onboarding_status) + end + + test "is a no-op when the site is already past the given status" do + site = insert(:site, onboarding_status: :completed) + + changeset = Site.put_onboarding_status_advance(site, :verification_succeeded) + + refute Ecto.Changeset.get_change(changeset, :onboarding_status) + end + + test "composes with other changes on the same changeset" do + site = insert(:site, onboarding_status: :new_site) + + changeset = + site + |> Site.set_stats_start_date(~D[2024-01-01]) + |> Site.put_onboarding_status_advance(:first_pageview) + + assert Ecto.Changeset.get_change(changeset, :stats_start_date) == ~D[2024-01-01] + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :first_pageview + end + + test "persists via Repo.update!" do + site = insert(:site, onboarding_status: :new_site) + + updated_site = + site + |> Site.put_onboarding_status_advance(:verification_succeeded) + |> Repo.update!() + + assert updated_site.onboarding_status == :verification_succeeded + assert Repo.reload!(site).onboarding_status == :verification_succeeded + end + end end From d1f6130e15d8677f82717743fc2b76118cecc1cd Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 13:21:03 +0100 Subject: [PATCH 24/43] advance status to verification_succeeded --- extra/lib/plausible_web/live/verification.ex | 8 ++ test/plausible_web/live/verification_test.exs | 95 +++++++++++++++++++ test/support/factory.ex | 3 +- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 1d9550a6b5da..0c75c4a9cd23 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -7,6 +7,8 @@ defmodule PlausibleWeb.Live.Verification do alias Plausible.InstallationSupport alias Plausible.InstallationSupport.State + alias Plausible.Repo + alias Plausible.Site @component PlausibleWeb.Live.Components.VerificationBanner @slowdown_for_frequent_checking :timer.seconds(5) @@ -174,6 +176,12 @@ defmodule PlausibleWeb.Live.Verification do def handle_info({:all_checks_done, %State{} = state}, socket) do interpretation = InstallationSupport.verification_checks_mod().interpret_diagnostics(state) + if interpretation.ok? do + socket.assigns.site + |> Site.put_onboarding_status_advance(:verification_succeeded) + |> Repo.update!() + end + update_component(socket, finished?: true, success?: interpretation.ok?, diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 664d4ce5c021..2b12b3f94592 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -5,6 +5,9 @@ defmodule PlausibleWeb.Live.VerificationTest do import Phoenix.LiveViewTest + alias Plausible.Repo + alias Plausible.Site + @moduletag :capture_log setup [:create_user, :log_in, :create_site] @@ -136,6 +139,98 @@ defmodule PlausibleWeb.Live.VerificationTest do end) end + @tag :ee_only + test "advances onboarding_status to :verification_succeeded on success", %{ + conn: conn, + site: site + } do + stub_dns() + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + {html =~ "Tracking is active on your site", html} + end) + + assert Repo.reload!(site).onboarding_status == :verification_succeeded + end + + @tag :ee_only + test "does not regress onboarding_status if already past :verification_succeeded", %{ + conn: conn, + site: site + } do + site + |> Site.put_onboarding_status_advance(:completed) + |> Repo.update!() + + stub_dns() + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + {html =~ "Tracking is active on your site", html} + end) + + assert Repo.reload!(site).onboarding_status == :completed + end + + @tag :ee_only + test "does not advance onboarding_status when verification fails", %{ + conn: conn, + site: site + } do + stub_dns() + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => false, + "plausibleIsOnWindow" => false, + "plausibleIsInitialized" => false + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text_of_element(html, @heading) =~ "We couldn't detect Plausible on your site", + html + } + end) + + assert Repo.reload!(site).onboarding_status == :new_site + end + @tag :ee_only test "the dismissed flag keeps the banner hidden even if a late update arrives while still connected", %{conn: conn, site: site} do diff --git a/test/support/factory.ex b/test/support/factory.ex index 6e2fa3a49032..e534bb72514b 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -85,7 +85,8 @@ defmodule Plausible.Factory do site = %Plausible.Site{ native_stats_start_at: ~N[2000-01-01 00:00:00], domain: domain, - timezone: "Etc/UTC" + timezone: "Etc/UTC", + onboarding_status: :new_site } merge_attributes(site, attrs) From 222a85af28a25b15477a209ec17d657d25093c13 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 13:55:50 +0100 Subject: [PATCH 25/43] fix setup pending pill condition (/sites page) --- lib/plausible_web/live/sites.ex | 2 +- test/plausible_web/live/sites_test.exs | 46 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index ed6b72daa1e7..5f2201a5e4e9 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -537,7 +537,7 @@ defmodule PlausibleWeb.Live.Sites do assign( assigns, :needs_verification?, - ee?() and is_nil(Plausible.Sites.stats_start_date(assigns.site)) + ee?() and assigns.site.onboarding_status == :new_site ) ~H""" diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs index add5590f8443..0deeec9fbd6a 100644 --- a/test/plausible_web/live/sites_test.exs +++ b/test/plausible_web/live/sites_test.exs @@ -152,6 +152,52 @@ defmodule PlausibleWeb.Live.SitesTest do end on_ee do + describe "pending setup badge and verification query parameter" do + @tag :ee_only + test "shows for a site with onboarding_status :new_site", %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :new_site) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + assert site_card =~ "Setup pending" + + dashboard_link_href = text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + assert dashboard_link_href =~ "verify_installation=true" + end + + for status <- [:verification_succeeded, :first_pageview, :completed] do + @tag :ee_only + test "does not show once onboarding_status has moved to #{status} (even with stats_start_date reset)", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: unquote(status), stats_start_date: nil) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + refute site_card =~ "Setup pending" + + dashboard_link_href = + text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + + refute dashboard_link_href =~ "verify_installation=true" + end + end + + @tag :ce_build_only + test "never shows on CE", %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :new_site) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + refute site_card =~ "Setup pending" + + dashboard_link_href = text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + refute dashboard_link_href =~ "verify_installation=true" + end + end + describe "consolidated views appearance" do test "consolidated view shows up", %{conn: conn, user: user} do new_site(owner: user) From 69f90e040f8a378f13e6511bac76b160b8936f5b Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 27 Jul 2026 14:41:05 +0100 Subject: [PATCH 26/43] further guard verification banner rendering in provisioning flow --- .../controllers/stats_controller.ex | 6 ++- .../controllers/stats_controller_test.exs | 48 +++++++++++++++++-- test/plausible_web/live/verification_test.exs | 11 +++-- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index d1cabbde2a1c..072050fe57f3 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -88,7 +88,11 @@ defmodule PlausibleWeb.StatsController do ee?() and not is_nil(current_user) and not consolidated_view? and - conn.params["verify_installation"] == "true" + conn.params["verify_installation"] == "true" and + (conn.params["flow"] in [ + PlausibleWeb.Flows.review(), + PlausibleWeb.Flows.domain_change() + ] or site.onboarding_status == :new_site) conn |> put_resp_header("x-robots-tag", "noindex, nofollow") diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index dd0ac8d8601b..464d8ce613aa 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -149,18 +149,58 @@ defmodule PlausibleWeb.StatsControllerTest do end on_ee do - test "verification banner only shows with the explicit param", + test "verification banner showing in the provisioning flow", %{ conn: conn, + user: user, site: site } do - resp = get(conn, "/#{site.domain}") |> html_response(200) - refute element_exists?(resp, @verification_banner) + get_dashboard_resp = fn conn, site, q -> + get(conn, "/#{site.domain}#{q}") |> html_response(200) + end - resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + q = "?verify_installation=true&flow=#{PlausibleWeb.Flows.provisioning()}" + + # No `?verify_installation=true` query parameter -> doesn't show + resp = get_dashboard_resp.(conn, site, "") + refute element_exists?(resp, @verification_banner) + # site.onboarding_status != :new_site -> doesn't show + for status <- [:verification_succeeded, :first_pageview, :completed] do + site = new_site(owner: user, onboarding_status: status) + resp = get_dashboard_resp.(conn, site, q) + refute element_exists?(resp, @verification_banner) + end + + # site.onboarding_status != :new_site & flow param not provided -> doesn't show + for status <- [:verification_succeeded, :first_pageview, :completed] do + site = new_site(owner: user, onboarding_status: status) + resp = get_dashboard_resp.(conn, site, "?verify_installation=true") + refute element_exists?(resp, @verification_banner) + end + + # both conditions met -> shows + resp = get_dashboard_resp.(conn, site, q) assert element_exists?(resp, @verification_banner) end + + for flow <- [PlausibleWeb.Flows.review(), PlausibleWeb.Flows.domain_change()] do + test "verification banner in #{flow} flow shows when verify_installation query param is present", + %{ + conn: conn, + site: site + } do + site + |> Plausible.Site.put_onboarding_status_advance(:completed) + |> Plausible.Repo.update!() + + resp = + get(conn, "/#{site.domain}?verify_installation=true&flow=#{unquote(flow)}") + |> html_response(200) + + assert element_exists?(resp, @verification_banner) + end + end end on_ee do diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 2b12b3f94592..73e3a22e7c58 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -193,7 +193,7 @@ defmodule PlausibleWeb.Live.VerificationTest do } }) - {:ok, lv} = kick_off_live_verification(conn, site) + {:ok, lv} = kick_off_live_verification(conn, site, PlausibleWeb.Flows.review()) assert eventually(fn -> html = render(lv) @@ -354,9 +354,9 @@ defmodule PlausibleWeb.Live.VerificationTest do {lv, html} end - defp kick_off_live_verification(conn, site) do + defp kick_off_live_verification(conn, site, flow \\ nil) do {:ok, lv, _html} = - conn |> no_slowdown() |> no_delay() |> as_live() |> live(verification_path(site)) + conn |> no_slowdown() |> no_delay() |> as_live() |> live(verification_path(site, flow)) {:ok, lv} end @@ -368,7 +368,10 @@ defmodule PlausibleWeb.Live.VerificationTest do # LiveView tests (e.g. props_settings_test.exs). defp as_live(conn), do: assign(conn, :live_module, PlausibleWeb.Live.Verification) - defp verification_path(site), do: "/#{site.domain}?verify_installation=true" + defp verification_path(site, flow \\ nil) do + base = "/#{site.domain}?verify_installation=true" + if flow, do: base <> "&flow=#{flow}", else: base + end defp no_slowdown(conn) do Plug.Conn.put_private(conn, :slowdown, 0) From 4f85806c2baec31addd3deeeba52bc077704a2c3 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 09:28:28 +0100 Subject: [PATCH 27/43] fix verification re-triggering on refresh (review/domain_change) --- assets/js/liveview/live_socket.js | 16 +++++++--- e2e/tests/dashboard/verification.spec.ts | 28 ++++++++++++++++ extra/lib/plausible_web/live/verification.ex | 19 +++++++---- test/plausible_web/live/verification_test.exs | 32 +++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index b68184de75f3..225913d12aac 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -18,13 +18,19 @@ let websocketUrl = document.querySelector("meta[name='websocket-url']") if (csrfToken && websocketUrl) { let Hooks = { Modal, Dropdown } - // Lets a LiveView tell the client to tear down the websocket connection - // once it's done with it (e.g. PlausibleWeb.Live.Verification, once its - // banner has been dismissed) - the server-side process then terminates - // gracefully. - Hooks.DisconnectSocket = { + Hooks.VerificationLifecycle = { mounted() { + // Lets a LiveView tell the client to tear down the websocket connection + // once it's done with it (e.g. PlausibleWeb.Live.Verification, once its + // banner has been dismissed) - the server-side process then terminates + // gracefully. this.handleEvent('disconnect-liveview', () => liveSocket.disconnect()) + + this.handleEvent('verification-succeeded', ({ queryParams }) => { + window.dispatchEvent( + new CustomEvent('verification-finished', { detail: { queryParams } }) + ) + }) } } diff --git a/e2e/tests/dashboard/verification.spec.ts b/e2e/tests/dashboard/verification.spec.ts index 00a7168b24cb..b7707c9c199f 100644 --- a/e2e/tests/dashboard/verification.spec.ts +++ b/e2e/tests/dashboard/verification.spec.ts @@ -46,3 +46,31 @@ test('verification success', async ({ page, request }) => { await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toBeHidden() }) + +test('review flow: success keeps the banner up but does not retrigger verification on refresh', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + + await setVerificationScenario({ + request, + domain, + scenario: 'success' + }) + + await page.goto(`/${domain}?verify_installation=true&flow=review`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + const banner = page.locator(VERIFICATION_BANNER_SELECTOR) + await expect(banner).toContainText(SUCCESS_MESSAGE) + await expect(banner).toBeVisible() + + await expect(page).not.toHaveURL(/verify_installation/) + await expect(page).not.toHaveURL(/flow=/) + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) +}) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 0c75c4a9cd23..f4f3ae5f4bf7 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -70,7 +70,7 @@ defmodule PlausibleWeb.Live.Verification do assigns = assign(assigns, :use_portal?, @use_portal?) ~H""" -
    +
    <%= if @use_portal? do %> <.portal id="verification-portal-source" target="#verification-portal-target"> <.verification_content {assigns} /> @@ -176,11 +176,18 @@ defmodule PlausibleWeb.Live.Verification do def handle_info({:all_checks_done, %State{} = state}, socket) do interpretation = InstallationSupport.verification_checks_mod().interpret_diagnostics(state) - if interpretation.ok? do - socket.assigns.site - |> Site.put_onboarding_status_advance(:verification_succeeded) - |> Repo.update!() - end + socket = + if interpretation.ok? do + socket.assigns.site + |> Site.put_onboarding_status_advance(:verification_succeeded) + |> Repo.update!() + + # When verification succeeds, we'll want to strip the URL params that + # trigger verification, so it doesn't kick off again on page refresh. + push_event(socket, "verification-succeeded", %{queryParams: @component.query_params()}) + else + socket + end update_component(socket, finished?: true, diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 73e3a22e7c58..2a51adfffdf3 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -169,6 +169,38 @@ defmodule PlausibleWeb.Live.VerificationTest do assert Repo.reload!(site).onboarding_status == :verification_succeeded end + for flow <- [PlausibleWeb.Flows.review(), PlausibleWeb.Flows.domain_change()] do + @tag :ee_only + test "advances onboarding_status to :verification_succeeded on first success via flow=#{flow}", + %{conn: conn, site: site} do + assert site.onboarding_status == :new_site + + stub_dns() + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site, unquote(flow)) + + assert eventually(fn -> + html = render(lv) + {html =~ "Tracking is active on your site", html} + end) + + assert Repo.reload!(site).onboarding_status == :verification_succeeded + end + end + @tag :ee_only test "does not regress onboarding_status if already past :verification_succeeded", %{ conn: conn, From fdfa0b2ce2f77d764af7fea01e4d344b16b140d0 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 10:55:45 +0100 Subject: [PATCH 28/43] add launch_delay option to MockScenarios --- e2e/tests/dashboard/verification.spec.ts | 2 +- e2e/tests/fixtures.ts | 2 +- .../installation_support/check_runner.ex | 12 +++- .../verification/checks.ex | 4 +- .../verification/checks_mock.ex | 4 +- .../verification/mock_scenarios.ex | 14 ++++- extra/lib/plausible_web/live/verification.ex | 17 +++--- .../check_runner_test.exs | 58 +++++++++++++++++++ .../verification/checks_mock_test.exs | 51 +++++++++++++--- .../checks_observability_test.exs | 6 +- .../verification/checks_test.exs | 12 ++-- .../verification/mock_scenarios_test.exs | 32 +++++++--- test/plausible_web/live/verification_test.exs | 2 +- .../support/dev/controllers/e2e_controller.ex | 5 +- 14 files changed, 182 insertions(+), 39 deletions(-) create mode 100644 test/plausible/installation_support/check_runner_test.exs diff --git a/e2e/tests/dashboard/verification.spec.ts b/e2e/tests/dashboard/verification.spec.ts index b7707c9c199f..0c33d1256c55 100644 --- a/e2e/tests/dashboard/verification.spec.ts +++ b/e2e/tests/dashboard/verification.spec.ts @@ -20,7 +20,7 @@ test('verification success', async ({ page, request }) => { request, domain, scenario: 'success', - options: { slowdown: 500 } + options: { slowdown: 500, launch_delay: 500 } }) await page.goto(`/${domain}?verify_installation=true`, { waitUntil: 'commit' }) diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts index 2d4c03d9b226..34eeabc2c59c 100644 --- a/e2e/tests/fixtures.ts +++ b/e2e/tests/fixtures.ts @@ -250,7 +250,7 @@ export async function setVerificationScenario({ request: APIRequestContext domain: string scenario: string - options?: { slowdown?: number } + options?: { slowdown?: number; launch_delay?: number } }) { const response = await request.put('/e2e-tests/verification', { headers: { diff --git a/extra/lib/plausible/installation_support/check_runner.ex b/extra/lib/plausible/installation_support/check_runner.ex index 73aa5f84555b..f847ebf4b875 100644 --- a/extra/lib/plausible/installation_support/check_runner.ex +++ b/extra/lib/plausible/installation_support/check_runner.ex @@ -12,20 +12,26 @@ defmodule Plausible.InstallationSupport.CheckRunner do Checks are normally run asynchronously, except when synchronous execution is optionally required for tests. Slowdowns can be optionally added, the user doesn't benefit from running the checks too quickly. + An optional `:launch_delay` is awaited once, before the first check + starts - callers that don't care about it (e.g. detection checks) can + simply omit the opt, since it defaults to 0 here. """ def run(state, checks, opts) do async? = Keyword.get(opts, :async?, true) slowdown = Keyword.get(opts, :slowdown, 500) + launch_delay = Keyword.get(opts, :launch_delay, 0) if async? do - Task.start_link(fn -> do_run(state, checks, slowdown) end) + Task.start_link(fn -> do_run(state, checks, slowdown, launch_delay) end) else - do_run(state, checks, slowdown) + do_run(state, checks, slowdown, launch_delay) end end - defp do_run(state, checks, slowdown) do + defp do_run(state, checks, slowdown, launch_delay) do + if is_integer(launch_delay) and launch_delay > 0, do: :timer.sleep(launch_delay) + state = Enum.reduce_while( checks, diff --git a/extra/lib/plausible/installation_support/verification/checks.ex b/extra/lib/plausible/installation_support/verification/checks.ex index 2a3328d21bcb..35f42b87a71a 100644 --- a/extra/lib/plausible/installation_support/verification/checks.ex +++ b/extra/lib/plausible/installation_support/verification/checks.ex @@ -23,6 +23,7 @@ defmodule Plausible.InstallationSupport.Verification.Checks do report_to = Keyword.get(opts, :report_to, self()) async? = Keyword.get(opts, :async?, true) slowdown = Keyword.get(opts, :slowdown, 500) + launch_delay = Keyword.get(opts, :launch_delay, 500) init_state = %State{ @@ -43,7 +44,8 @@ defmodule Plausible.InstallationSupport.Verification.Checks do CheckRunner.run(init_state, checks, async?: async?, report_to: report_to, - slowdown: slowdown + slowdown: slowdown, + launch_delay: launch_delay ) end diff --git a/extra/lib/plausible/installation_support/verification/checks_mock.ex b/extra/lib/plausible/installation_support/verification/checks_mock.ex index bcec4ec67550..7650c56917cf 100644 --- a/extra/lib/plausible/installation_support/verification/checks_mock.ex +++ b/extra/lib/plausible/installation_support/verification/checks_mock.ex @@ -73,6 +73,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMock do report_to = Keyword.get(opts, :report_to, self()) async? = Keyword.get(opts, :async?, true) slowdown = scenario.slowdown || Keyword.get(opts, :slowdown, 500) + launch_delay = scenario.launch_delay || Keyword.get(opts, :launch_delay, 500) init_state = %State{ url: url || "https://#{data_domain}", @@ -90,7 +91,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMock do CheckRunner.run(init_state, checks, async?: async?, report_to: report_to, - slowdown: slowdown + slowdown: slowdown, + launch_delay: launch_delay ) end diff --git a/extra/lib/plausible/installation_support/verification/mock_scenarios.ex b/extra/lib/plausible/installation_support/verification/mock_scenarios.ex index 1c648cf03d28..2c3d9e76d7f8 100644 --- a/extra/lib/plausible/installation_support/verification/mock_scenarios.ex +++ b/extra/lib/plausible/installation_support/verification/mock_scenarios.ex @@ -9,7 +9,11 @@ defmodule Plausible.InstallationSupport.Verification.MockScenarios do use GenServer - @type scenario :: %{interpretation_result: atom(), slowdown: non_neg_integer() | nil} + @type scenario :: %{ + interpretation_result: atom(), + slowdown: non_neg_integer() | nil, + launch_delay: non_neg_integer() | nil + } def start_link(_opts) do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) @@ -24,10 +28,16 @@ defmodule Plausible.InstallationSupport.Verification.MockScenarios do ### Opts * `:slowdown` - overrides the check pipeline's default per-check delay + * `:launch_delay` - overrides the delay before the first check starts """ @spec put(String.t(), atom(), Keyword.t()) :: :ok def put(domain, key, opts \\ []) when is_binary(domain) and is_atom(key) do - scenario = %{interpretation_result: key, slowdown: Keyword.get(opts, :slowdown)} + scenario = %{ + interpretation_result: key, + slowdown: Keyword.get(opts, :slowdown), + launch_delay: Keyword.get(opts, :launch_delay) + } + GenServer.call(__MODULE__, {:put, domain, scenario}) end diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index f4f3ae5f4bf7..920388f5a66e 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -50,7 +50,7 @@ defmodule PlausibleWeb.Live.Verification do component: @component, tracker_script_configuration: tracker_script_configuration, report_to: self(), - delay: private[:delay] || 500, + launch_delay: private[:launch_delay] || 500, slowdown: private[:slowdown] || 500, flow: session["flow"] || "", checks_pid: nil, @@ -60,7 +60,7 @@ defmodule PlausibleWeb.Live.Verification do ) if connected?(socket) do - launch_delayed(socket) + start_verification(socket) end {:ok, socket} @@ -98,12 +98,12 @@ defmodule PlausibleWeb.Live.Verification do end def handle_event("launch-verification", _, socket) do - launch_delayed(socket) + start_verification(socket) {:noreply, reset_component(socket)} end def handle_event("retry", _, socket) do - launch_delayed(socket) + start_verification(socket) {:noreply, reset_component(socket)} end @@ -125,7 +125,7 @@ defmodule PlausibleWeb.Live.Verification do |> assign(url_to_verify: custom_url) |> assign(custom_url_input?: false) - launch_delayed(socket) + start_verification(socket) {:noreply, reset_component(socket)} end @@ -151,7 +151,8 @@ defmodule PlausibleWeb.Live.Verification do domain, get_installation_type(socket.assigns.tracker_script_configuration), report_to: report_to, - slowdown: socket.assigns.slowdown + slowdown: socket.assigns.slowdown, + launch_delay: socket.assigns.launch_delay ) {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)} @@ -229,7 +230,7 @@ defmodule PlausibleWeb.Live.Verification do ) end - defp launch_delayed(socket) do - Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay) + defp start_verification(socket) do + send(self(), {:start, socket.assigns.report_to}) end end diff --git a/test/plausible/installation_support/check_runner_test.exs b/test/plausible/installation_support/check_runner_test.exs new file mode 100644 index 000000000000..752391bdc23f --- /dev/null +++ b/test/plausible/installation_support/check_runner_test.exs @@ -0,0 +1,58 @@ +defmodule Plausible.InstallationSupport.CheckRunnerTest do + use Plausible.DataCase, async: true + + on_ee do + alias Plausible.InstallationSupport.{CheckRunner, State} + + defmodule NoopCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: "noop" + + @impl true + def perform(state, _opts), do: state + end + + defp init_state do + %State{url: "https://example.com", data_domain: "example.com", report_to: self()} + end + + describe "run/3" do + test "defaults launch_delay to 0" do + started_at = System.monotonic_time(:millisecond) + + CheckRunner.run(init_state(), [{NoopCheck, []}], async?: false, slowdown: 0) + + assert System.monotonic_time(:millisecond) - started_at < 100 + end + + test "awaits :launch_delay before the first check starts" do + started_at = System.monotonic_time(:millisecond) + + {:ok, _pid} = + CheckRunner.run(init_state(), [{NoopCheck, []}], + slowdown: 0, + launch_delay: 100, + report_to: self() + ) + + assert_receive {:check_start, {NoopCheck, _state}}, 1000 + + assert System.monotonic_time(:millisecond) - started_at >= 100 + end + + test "runs the first check immediately when :launch_delay is 0" do + {:ok, _pid} = + CheckRunner.run(init_state(), [{NoopCheck, []}], + slowdown: 0, + launch_delay: 0, + report_to: self() + ) + + assert_receive {:check_start, {NoopCheck, _state}}, 100 + end + end + end +end diff --git a/test/plausible/installation_support/verification/checks_mock_test.exs b/test/plausible/installation_support/verification/checks_mock_test.exs index 979d4ae91942..c4d2bf0c7c62 100644 --- a/test/plausible/installation_support/verification/checks_mock_test.exs +++ b/test/plausible/installation_support/verification/checks_mock_test.exs @@ -12,7 +12,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do domain = insert(:site).domain assert_raise RuntimeError, ~r/no scenario is registered/, fn -> - ChecksMock.run(@url, domain, "manual", async?: false, slowdown: 0, report_to: nil) + ChecksMock.run(@url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) end end @@ -21,7 +26,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do :ok = MockScenarios.put(domain, :success) state = - ChecksMock.run(@url, domain, "wordpress", async?: false, slowdown: 0, report_to: nil) + ChecksMock.run(@url, domain, "wordpress", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) assert state.url == @url assert state.data_domain == domain @@ -32,7 +42,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do domain = insert(:site).domain :ok = MockScenarios.put(domain, :success) - ChecksMock.run(@url, domain, "manual", async?: false, slowdown: 0, report_to: self()) + ChecksMock.run(@url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: self() + ) assert_received {:check_start, {ChecksMock.FakeUrlCheck, _state}} assert_received {:check_start, {ChecksMock.FakeVerifyInstallationCheck, _state}} @@ -53,7 +68,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do domain = insert(:site).domain :ok = MockScenarios.put(domain, :success) - state = ChecksMock.run(nil, domain, "manual", async?: false, slowdown: 0, report_to: nil) + state = + ChecksMock.run(nil, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) assert state.url == "https://#{domain}" end @@ -64,7 +85,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do domain = insert(:site).domain :ok = MockScenarios.put(domain, :success) - state = ChecksMock.run(@url, domain, "manual", async?: false, slowdown: 0, report_to: nil) + state = + ChecksMock.run(@url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) assert %Result{ok?: true} = ChecksMock.interpret_diagnostics(state) end @@ -74,7 +101,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do :ok = MockScenarios.put(domain, :plausible_not_found) state = - ChecksMock.run(@url, domain, "wordpress", async?: false, slowdown: 0, report_to: nil) + ChecksMock.run(@url, domain, "wordpress", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) assert %Result{ ok?: false, @@ -91,7 +123,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do custom_url = "https://abc.de" state = - ChecksMock.run(custom_url, domain, "manual", async?: false, slowdown: 0, report_to: nil) + ChecksMock.run(custom_url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) assert %Result{errors: [error]} = ChecksMock.interpret_diagnostics(state) assert error =~ custom_url diff --git a/test/plausible/installation_support/verification/checks_observability_test.exs b/test/plausible/installation_support/verification/checks_observability_test.exs index 5464c08c019a..eda236fcc05e 100644 --- a/test/plausible/installation_support/verification/checks_observability_test.exs +++ b/test/plausible/installation_support/verification/checks_observability_test.exs @@ -136,7 +136,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksObservabilityTest do verify_installation_check_timeout: 100, report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) log = capture_log(fn -> Checks.interpret_diagnostics(state) end) @@ -167,7 +168,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksObservabilityTest do Checks.run(@url_to_verify, @expected_domain, "manual", report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) end diff --git a/test/plausible/installation_support/verification/checks_test.exs b/test/plausible/installation_support/verification/checks_test.exs index 7b8118c9aad7..f893a490e604 100644 --- a/test/plausible/installation_support/verification/checks_test.exs +++ b/test/plausible/installation_support/verification/checks_test.exs @@ -40,7 +40,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do Checks.run(@url_to_verify, @expected_domain, "manual", report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) |> Checks.interpret_diagnostics() end @@ -66,7 +67,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do Checks.run(url_to_verify, @expected_domain, "manual", report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) |> Checks.interpret_diagnostics() end @@ -561,7 +563,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do Checks.run(@url_to_verify, @expected_domain, installation_type, report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) assert expected_req_count == :atomics.get(counter, 1) @@ -573,7 +576,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do Checks.run(@url_to_verify, @expected_domain, installation_type, report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) end end diff --git a/test/plausible/installation_support/verification/mock_scenarios_test.exs b/test/plausible/installation_support/verification/mock_scenarios_test.exs index bcf7f2d93a3a..ed191e6cc410 100644 --- a/test/plausible/installation_support/verification/mock_scenarios_test.exs +++ b/test/plausible/installation_support/verification/mock_scenarios_test.exs @@ -17,7 +17,8 @@ defmodule Plausible.InstallationSupport.Verification.MockScenariosTest do assert MockScenarios.get(site.domain) == %{ interpretation_result: :success, - slowdown: nil + slowdown: nil, + launch_delay: nil } end @@ -28,7 +29,20 @@ defmodule Plausible.InstallationSupport.Verification.MockScenariosTest do assert MockScenarios.get(site.domain) == %{ interpretation_result: :domain_not_found, - slowdown: 2000 + slowdown: 2000, + launch_delay: nil + } + end + + test "put/3 stores a launch_delay opt alongside the interpretation result" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :domain_not_found, launch_delay: 2000) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: nil, + launch_delay: 2000 } end @@ -40,7 +54,8 @@ defmodule Plausible.InstallationSupport.Verification.MockScenariosTest do assert MockScenarios.get(site.domain) == %{ interpretation_result: :csp_disallowed, - slowdown: nil + slowdown: nil, + launch_delay: nil } end @@ -49,23 +64,26 @@ defmodule Plausible.InstallationSupport.Verification.MockScenariosTest do site_b = insert(:site) :ok = MockScenarios.put(site_a.domain, :success, []) - :ok = MockScenarios.put(site_b.domain, :domain_not_found, slowdown: 500) + :ok = MockScenarios.put(site_b.domain, :domain_not_found, slowdown: 500, launch_delay: 100) assert MockScenarios.get(site_a.domain) == %{ interpretation_result: :success, - slowdown: nil + slowdown: nil, + launch_delay: nil } assert MockScenarios.get(site_b.domain) == %{ interpretation_result: :domain_not_found, - slowdown: 500 + slowdown: 500, + launch_delay: 100 } :ok = MockScenarios.put(site_a.domain, :csp_disallowed, []) assert MockScenarios.get(site_b.domain) == %{ interpretation_result: :domain_not_found, - slowdown: 500 + slowdown: 500, + launch_delay: 100 } end end diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 2a51adfffdf3..72240e4ae996 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -410,7 +410,7 @@ defmodule PlausibleWeb.Live.VerificationTest do end defp no_delay(conn) do - Plug.Conn.put_private(conn, :delay, 0) + Plug.Conn.put_private(conn, :launch_delay, 0) end defp stub_verification_result(js_data) do diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex index fa1ed838de98..fce453814a69 100644 --- a/test/support/dev/controllers/e2e_controller.ex +++ b/test/support/dev/controllers/e2e_controller.ex @@ -99,7 +99,10 @@ defmodule PlausibleWeb.E2EController do def put_verification_scenario(conn, %{"domain" => domain, "scenario" => scenario} = params) do key = String.to_existing_atom(scenario) - opts = [slowdown: params["options"]["slowdown"] || 0] + opts = [ + slowdown: params["options"]["slowdown"] || 0, + launch_delay: params["options"]["launch_delay"] || 0 + ] :ok = Plausible.InstallationSupport.Verification.MockScenarios.put(domain, key, opts) From 766d08d7b6153055e15653684f211cb9fdaf07d8 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 13:07:53 +0100 Subject: [PATCH 29/43] fix site switcher links + more e2e test coverage --- assets/js/dashboard/nav-menu/top-bar.test.tsx | 23 +++ assets/js/dashboard/site-switcher.tsx | 62 ++++--- e2e/tests/dashboard/verification.spec.ts | 174 +++++++++++++----- .../controllers/api/internal_controller.ex | 6 +- .../api/internal_controller_test.exs | 29 ++- .../support/dev/controllers/e2e_controller.ex | 12 +- 6 files changed, 231 insertions(+), 75 deletions(-) diff --git a/assets/js/dashboard/nav-menu/top-bar.test.tsx b/assets/js/dashboard/nav-menu/top-bar.test.tsx index d2a1d645d709..be3cacf1b2a7 100644 --- a/assets/js/dashboard/nav-menu/top-bar.test.tsx +++ b/assets/js/dashboard/nav-menu/top-bar.test.tsx @@ -75,6 +75,29 @@ test('user can open and close site switcher', async () => { expect(screen.queryAllByRole('menuitem')).toEqual([]) }) +test('site switcher links to a site needing verification with verify_installation and flow params', async () => { + mockAPI.get('/api/sites', { + data: [ + { domain, needs_verification: false }, + { domain: 'example.com', needs_verification: true } + ] + }) + + render(, { + wrapper: (props) => ( + + ) + }) + + const toggleSiteSwitcher = screen.getByRole('button', { name: domain }) + await userEvent.click(toggleSiteSwitcher) + + expect(screen.getByRole('link', { name: /example\.com/ })).toHaveAttribute( + 'href', + '/example.com?verify_installation=true&flow=provisioning' + ) +}) + test('user can open and close filters dropdown', async () => { render(, { wrapper: (props) => ( diff --git a/assets/js/dashboard/site-switcher.tsx b/assets/js/dashboard/site-switcher.tsx index 637dd5aa3d98..8dd48bacbc55 100644 --- a/assets/js/dashboard/site-switcher.tsx +++ b/assets/js/dashboard/site-switcher.tsx @@ -58,13 +58,17 @@ const buttonLinkClassName = classNames( const getSwitchToSiteURL = ( currentSite: PlausibleSite, - site: { domain: string } + site: { domain: string; needsVerification?: boolean } ): string | null => { // Prevents reloading the page when the current site is selected if (currentSite.domain === site.domain) { return null } - return `/${encodeURIComponent(site.domain)}` + const url = `/${encodeURIComponent(site.domain)}` + + return site.needsVerification + ? `${url}?verify_installation=true&flow=provisioning` + : url } const SiteSwitcherStatic = () => { @@ -96,7 +100,9 @@ export const SiteSwitcher = () => { const sitesQuery = useQuery({ enabled: user.loggedIn, queryKey: ['sites'], - queryFn: async (): Promise<{ data: Array<{ domain: string }> }> => { + queryFn: async (): Promise<{ + data: Array<{ domain: string; needs_verification: boolean }> + }> => { const response = await get('/api/sites') return response }, @@ -121,24 +127,29 @@ export const SiteSwitcher = () => { <> {!!dashboardRouteMatch && !modal && - sitesQuery.data?.data.slice(0, 8).map(({ domain }, index) => ( - { - const url = getSwitchToSiteURL(currentSite, { domain }) - if (!url) { - closePopover() - } else { - closePopover() - window.location.assign(url) - } - }} - shouldIgnoreWhen={[isModifierPressed, isTyping]} - targetRef="document" - /> - ))} + sitesQuery.data?.data + .slice(0, 8) + .map(({ domain, needs_verification }, index) => ( + { + const url = getSwitchToSiteURL(currentSite, { + domain, + needsVerification: needs_verification + }) + if (!url) { + closePopover() + } else { + closePopover() + window.location.assign(url) + } + }} + shouldIgnoreWhen={[isModifierPressed, isTyping]} + targetRef="document" + /> + ))} {!!dashboardRouteMatch && !modal && @@ -253,12 +264,17 @@ export const SiteSwitcher = () => { )} {!!sitesInDropdown && - sitesInDropdown.map(({ domain }, index) => ( + sitesInDropdown.map(({ domain, needs_verification }, index) => ( closePopover() diff --git a/e2e/tests/dashboard/verification.spec.ts b/e2e/tests/dashboard/verification.spec.ts index 0c33d1256c55..a2df3947cff9 100644 --- a/e2e/tests/dashboard/verification.spec.ts +++ b/e2e/tests/dashboard/verification.spec.ts @@ -1,11 +1,13 @@ import { test, expect } from '@playwright/test' -import { setupSite, setVerificationScenario } from '../fixtures' +import { addSite, setupSite, setVerificationScenario } from '../fixtures' import { expectLiveViewConnected } from '../test-utils' const VERIFICATION_BANNER_SELECTOR = '#verification-ui' const PROGRESS_MSG_SELECTOR = '#progress' +const HEADING_SELECTOR = 'h3' const SUCCESS_MESSAGE = 'Tracking is active on your site' +const FAILURE_HEADING = "We couldn't detect Plausible on your site" const LOADING_STATE_TITLE = 'Verifying your installation' const LOADING_STATE_CYCLED_MESSAGES = [ /We're visiting your site to ensure that everything is working/, @@ -13,64 +15,154 @@ const LOADING_STATE_CYCLED_MESSAGES = [ /We're verifying that your visitors are being counted correctly/ ] -test('verification success', async ({ page, request }) => { +test('installation verification', async ({ page, request }) => { const { domain } = await setupSite({ page, request }) - await setVerificationScenario({ - request, - domain, - scenario: 'success', - options: { slowdown: 500, launch_delay: 500 } - }) - - await page.goto(`/${domain}?verify_installation=true`, { waitUntil: 'commit' }) - await expectLiveViewConnected(page) + const otherDomain = `other.verification.com` + await addSite({ page, domain: otherDomain }) const banner = page.locator(VERIFICATION_BANNER_SELECTOR) const progress = banner.locator(PROGRESS_MSG_SELECTOR) + const heading = banner.locator(HEADING_SELECTOR) - await expect(banner).toContainText(LOADING_STATE_TITLE) + await test.step('restarts when the page is refreshed while verification is ongoing', async () => { + await setVerificationScenario({ + request, + domain, + scenario: 'plausible_not_found', + options: { slowdown: 500, launch_delay: 500 } + }) - for (const msg of LOADING_STATE_CYCLED_MESSAGES) { - await expect(progress).toHaveText(msg) - } + await page.goto(`/${domain}?verify_installation=true`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) - await expect(banner).toContainText(SUCCESS_MESSAGE) + await expect(banner).toContainText(LOADING_STATE_TITLE) + await expect(progress).toHaveText(LOADING_STATE_CYCLED_MESSAGES[0]!) + await expect(progress).toHaveText(LOADING_STATE_CYCLED_MESSAGES[1]!) - await banner.getByRole('button', { name: 'Dismiss' }).click() + await page.reload({ waitUntil: 'commit' }) + await expectLiveViewConnected(page) - await expect(banner).toBeHidden() - await expect(page).not.toHaveURL(/verify_installation/) + await expect(banner).toContainText(LOADING_STATE_TITLE) - await page.reload({ waitUntil: 'commit' }) + for (const msg of LOADING_STATE_CYCLED_MESSAGES) { + await expect(progress).toHaveText(msg) + } + }) - await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toBeHidden() -}) + await test.step('when finished with failure, refreshing kicks off verification again', async () => { + await expect(heading).toHaveText(FAILURE_HEADING) -test('review flow: success keeps the banner up but does not retrigger verification on refresh', async ({ - page, - request -}) => { - const { domain } = await setupSite({ page, request }) + await page.reload({ waitUntil: 'commit' }) + await expectLiveViewConnected(page) - await setVerificationScenario({ - request, - domain, - scenario: 'success' + await expect(banner).toContainText(LOADING_STATE_TITLE) }) - await page.goto(`/${domain}?verify_installation=true&flow=review`, { - waitUntil: 'commit' + await test.step('navigating to the dashboard via the site switcher kicks off verification again', async () => { + await setVerificationScenario({ request, domain, scenario: 'success' }) + + await page.goto(`/${otherDomain}`, { waitUntil: 'commit' }) + + await page.getByRole('button', { name: otherDomain }).click() + await page + .getByTestId('sitemenu') + .getByRole('link', { name: new RegExp(domain) }) + .click() + + await expect(page).toHaveURL( + new RegExp(`${domain}\\?verify_installation=true&flow=provisioning`) + ) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(SUCCESS_MESSAGE) }) - await expectLiveViewConnected(page) - const banner = page.locator(VERIFICATION_BANNER_SELECTOR) - await expect(banner).toContainText(SUCCESS_MESSAGE) - await expect(banner).toBeVisible() + await test.step("when finished with success, a page refresh won't bring it back", async () => { + await page.reload({ waitUntil: 'commit' }) - await expect(page).not.toHaveURL(/verify_installation/) - await expect(page).not.toHaveURL(/flow=/) - await page.reload({ waitUntil: 'commit' }) + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) + + await test.step("flow=review, dismissing while in progress: refresh won't bring it back", async () => { + await setVerificationScenario({ + request, + domain, + scenario: 'success', + options: { slowdown: 3000 } + }) + + await page.goto(`/${domain}?verify_installation=true&flow=review`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(LOADING_STATE_TITLE) + + await banner.getByRole('button', { name: 'Dismiss' }).click() + + await expect(banner).toBeHidden() + await expect(page).not.toHaveURL(/verify_installation/) + await expect(page).not.toHaveURL(/flow=/) + + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) - await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + await test.step('flow=review, finished with failure: refresh kicks off verification again', async () => { + await setVerificationScenario({ + request, + domain, + scenario: 'plausible_not_found' + }) + + await page.goto(`/${domain}?verify_installation=true&flow=review`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(heading).toHaveText(FAILURE_HEADING) + + await page.reload({ waitUntil: 'commit' }) + await expectLiveViewConnected(page) + + await expect(heading).toHaveText(FAILURE_HEADING) + }) + + await test.step("flow=domain_change, dismissing after finished with failure: refresh won't bring it back", async () => { + await page.goto(`/${domain}?verify_installation=true&flow=domain_change`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(heading).toHaveText(FAILURE_HEADING) + + await banner.getByRole('button', { name: 'Dismiss' }).click() + + await expect(banner).toBeHidden() + await expect(page).not.toHaveURL(/verify_installation/) + await expect(page).not.toHaveURL(/flow=/) + + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) + + await test.step("flow=domain_change, finished with success: refresh won't bring it back", async () => { + await setVerificationScenario({ request, domain, scenario: 'success' }) + + await page.goto(`/${domain}?verify_installation=true&flow=domain_change`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(SUCCESS_MESSAGE) + + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) }) diff --git a/lib/plausible_web/controllers/api/internal_controller.ex b/lib/plausible_web/controllers/api/internal_controller.ex index 821fe58e58b6..aa5e3b0c644b 100644 --- a/lib/plausible_web/controllers/api/internal_controller.ex +++ b/lib/plausible_web/controllers/api/internal_controller.ex @@ -1,4 +1,5 @@ defmodule PlausibleWeb.Api.InternalController do + use Plausible use PlausibleWeb, :controller use Plausible.Repo import Ecto.Query @@ -63,7 +64,10 @@ defmodule PlausibleWeb.Api.InternalController do on: u.site_id == s.id, left_join: up in Plausible.Site.UserPreference, on: up.site_id == s.id and up.user_id == ^user.id, - select: %{domain: s.domain}, + select: %{ + domain: s.domain, + needs_verification: ^ee?() and s.onboarding_status == :new_site + }, order_by: [ asc: fragment( diff --git a/test/plausible_web/controllers/api/internal_controller_test.exs b/test/plausible_web/controllers/api/internal_controller_test.exs index 1039882381e3..7747efe1d896 100644 --- a/test/plausible_web/controllers/api/internal_controller_test.exs +++ b/test/plausible_web/controllers/api/internal_controller_test.exs @@ -11,9 +11,22 @@ defmodule PlausibleWeb.Api.InternalControllerTest do conn = get(conn, "/api/sites") %{"data" => sites} = json_response(conn, 200) + domains = Enum.map(sites, & &1["domain"]) - assert %{"domain" => site.domain} in sites - assert %{"domain" => site2.domain} in sites + assert site.domain in domains + assert site2.domain in domains + end + + @tag :ee_only + test "needs_verification reflects the site's onboarding status", %{conn: conn, user: user} do + new_site_ = new_site(owner: user, onboarding_status: :new_site) + onboarded_site = new_site(owner: user, onboarding_status: :completed) + + conn = get(conn, "/api/sites") + %{"data" => sites} = json_response(conn, 200) + + assert %{"domain" => new_site_.domain, "needs_verification" => true} in sites + assert %{"domain" => onboarded_site.domain, "needs_verification" => false} in sites end test "returns a list of max 9 site domains for the current user, putting pinned first", %{ @@ -44,16 +57,14 @@ defmodule PlausibleWeb.Api.InternalControllerTest do %{"data" => sites} = json_response(conn, 200) + domains = Enum.map(sites, & &1["domain"]) + assert Enum.count(sites) == 9 - assert [ - %{"domain" => "site05.example.com"}, - %{"domain" => "site07.example.com"}, - %{"domain" => "site01.example.com"} | _ - ] = sites + assert ["site05.example.com", "site07.example.com", "site01.example.com" | _] = domains - assert %{"domain" => "site09.example.com"} in sites - refute %{"domain" => "sites10.example.com"} in sites + assert "site09.example.com" in domains + refute "sites10.example.com" in domains end end diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex index fce453814a69..5682d9bff8e3 100644 --- a/test/support/dev/controllers/e2e_controller.ex +++ b/test/support/dev/controllers/e2e_controller.ex @@ -51,6 +51,7 @@ defmodule PlausibleWeb.E2EController do site |> Plausible.Site.set_native_stats_start_at(stats_start_time) |> Plausible.Site.set_stats_start_date(stats_start_date) + |> Plausible.Site.put_onboarding_status_advance(:first_pageview) |> Plausible.Repo.update!() populate(events, site) @@ -97,13 +98,22 @@ defmodule PlausibleWeb.E2EController do end def put_verification_scenario(conn, %{"domain" => domain, "scenario" => scenario} = params) do - key = String.to_existing_atom(scenario) + # Using `String.to_atom/1` is safe here because this is test-only code + # routed only under Mix.env() == :e2e_test. + key = String.to_atom(scenario) opts = [ slowdown: params["options"]["slowdown"] || 0, launch_delay: params["options"]["launch_delay"] || 0 ] + # Registering a new scenario is treated as the start of a fresh + # verification "phase" in e2e specs - reset the rate limit so a spec + # exercising multiple scenarios/flows against the same domain doesn't + # hit the real per-domain verification rate limit. + rate_limit_key = "site_verification:#{domain}" + :ets.select_delete(Plausible.RateLimit, [{{{rate_limit_key, :_}, :_, :_}, [], [true]}]) + :ok = Plausible.InstallationSupport.Verification.MockScenarios.put(domain, key, opts) send_resp(conn, 200, Jason.encode!(%{"ok" => true})) From a7293cc2e3ee8ae72f1594cc2ab3a76e70181ba2 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 15:51:09 +0100 Subject: [PATCH 30/43] new logic for email reports CTA --- .../email-reports-cta-banner.test.tsx | 87 ++++++++++++++ .../js/dashboard/email-reports-cta-banner.tsx | 110 +++++------------- assets/js/dashboard/site-context.test.tsx | 3 +- assets/js/dashboard/site-context.tsx | 6 +- assets/test-utils/app-context-providers.tsx | 3 +- assets/test-utils/mock-api.ts | 8 ++ lib/plausible/sites.ex | 1 + .../controllers/api/internal_controller.ex | 18 +++ .../controllers/site_controller.ex | 8 +- .../controllers/stats_controller.ex | 7 ++ lib/plausible_web/router.ex | 1 + .../templates/stats/stats.html.heex | 1 + test/plausible/sites_test.exs | 20 ++++ .../api/internal_controller_test.exs | 74 ++++++++++++ .../controllers/site_controller_test.exs | 40 +++++++ .../controllers/stats_controller_test.exs | 106 +++++++++++++++++ 16 files changed, 405 insertions(+), 88 deletions(-) create mode 100644 assets/js/dashboard/email-reports-cta-banner.test.tsx diff --git a/assets/js/dashboard/email-reports-cta-banner.test.tsx b/assets/js/dashboard/email-reports-cta-banner.test.tsx new file mode 100644 index 000000000000..e4f707c785ea --- /dev/null +++ b/assets/js/dashboard/email-reports-cta-banner.test.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TestContextProviders } from '../../test-utils/app-context-providers' +import { MockAPI } from '../../test-utils/mock-api' +import { EmailReportsCTABanner } from './email-reports-cta-banner' + +const domain = 'dummy.site' + +let mockAPI: MockAPI + +beforeAll(() => { + mockAPI = new MockAPI().start() +}) + +afterAll(() => { + mockAPI.stop() +}) + +beforeEach(() => { + mockAPI.clear() +}) + +function renderBanner(showEmailReportsCta: boolean) { + render(, { + wrapper: (props) => ( + + ) + }) +} + +test('renders nothing when showEmailReportsCta is false', () => { + renderBanner(false) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + +test('renders the banner when showEmailReportsCta is true', () => { + renderBanner(true) + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Your first pageview has landed!' + ) +}) + +test('dismissing fires the mutation and hides the banner', async () => { + const putHandler = mockAPI.put(`/api/${domain}/complete-onboarding`, {}) + + renderBanner(true) + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + await waitFor(() => expect(putHandler).toHaveBeenCalledTimes(1)) +}) + +test('the email reports link points at the settings page with cta_clicked=true', async () => { + renderBanner(true) + + const link = screen.getByRole('link', { + name: /Get weekly traffic reports by email/ + }) + + expect(link).toHaveAttribute( + 'href', + `/${domain}/settings/email-reports?cta_clicked=true` + ) +}) + +test('still hides the banner (and does not throw) when the mutation fails', async () => { + mockAPI.put(`/api/${domain}/complete-onboarding`, () => + Promise.resolve({ + ok: false, + status: 401, + json: async () => ({ error: 'unauthorized' }) + } as Response) + ) + + renderBanner(true) + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) diff --git a/assets/js/dashboard/email-reports-cta-banner.tsx b/assets/js/dashboard/email-reports-cta-banner.tsx index 6a69fb9d0c39..14d6895db989 100644 --- a/assets/js/dashboard/email-reports-cta-banner.tsx +++ b/assets/js/dashboard/email-reports-cta-banner.tsx @@ -1,98 +1,42 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useState } from 'react' import { XMarkIcon } from '@heroicons/react/24/outline' import { useSiteContext } from './site-context' -import { useCurrentVisitorsContext } from './current-visitors-context' +import * as api from './api' -type CTAStorageState = 'pending' | 'visible' - -function getStorageKey(domain: string) { - return `email_reports_cta_${domain}` -} - -// CTA for configuring weekly email reports -// -// Renders only once, as soon as the first pageview lands. This can happen: -// -// 1. Automatically, when the dashboard stays open -- relying on the value -// of current-visitors changing to something other than 0. +// CTA for configuring weekly email reports. It displays when the +// dashboard is loaded for the very first time, having actual data +// (i.e. site.onboarding_status == "first_pageview"). The CTA will +// remain visible until either: // -// 2. Dashboard is refreshed and showing data for the very first time. +// 1) it's dismissed by any site member that sees it -- +// Notifies the backend to advance the site onboarding status via +// a HTTP request. // -// Case 2 is the tricky one. By the time of the refresh, `site.statsBegin` -// is already set, so that value alone can't distinguish "stats just -// started" from "this site has always had stats". -// -// The sessionStorage entry closes that gap -- it's stamped 'pending' the -// moment stats are still absent, so a later reload can still recognize the -// transition. It is only ever stamped while stats are absent, so established -// sites never pick it up and can't retrigger the CTA. -// -// Once shown, the same entry is stamped 'visible', so a refresh mid-display -// resumes the CTA instead of re-deciding from scratch -- but only for three -// seconds -- past that, the sessionStorage entry clears itself out and a -// refresh won't bring the CTA back. +// 2) the CTA is clicked by any site member that sees it -- +// The link to /:domain/settings/email-reports includes a query +// parameter telling the controller action to advance the site's +// onboarding status. export function EmailReportsCTABanner() { const site = useSiteContext() - const currentVisitors = useCurrentVisitorsContext() - const hasStats = !!site.statsBegin - const storageKey = getStorageKey(site.domain) - - const hasTriggeredRef = useRef(false) - const [visible, setVisible] = useState(false) - - useEffect(() => { - if (!hasStats && sessionStorage.getItem(storageKey) !== 'visible') { - const state: CTAStorageState = 'pending' - sessionStorage.setItem(storageKey, state) - } - }, [hasStats, storageKey]) - - useEffect(() => { - if (hasTriggeredRef.current) { - return - } - - const storedState = sessionStorage.getItem(storageKey) - - if (storedState === 'visible') { - hasTriggeredRef.current = true - setVisible(true) - return - } - - const firstPageviewJustLanded = hasStats - ? storedState === 'pending' - : !!currentVisitors - - if (!firstPageviewJustLanded) { - return - } - - hasTriggeredRef.current = true - const state: CTAStorageState = 'visible' - sessionStorage.setItem(storageKey, state) - setVisible(true) - }, [hasStats, currentVisitors, storageKey]) - - useEffect(() => { - if (!visible) { - return - } - - const timeout = setTimeout(() => { - sessionStorage.removeItem(storageKey) - }, 3000) - - return () => clearTimeout(timeout) - }, [visible, storageKey]) + const [visible, setVisible] = useState(site.showEmailReportsCta) if (!visible) { return null } function dismiss() { - sessionStorage.removeItem(storageKey) setVisible(false) + + api + .mutation(`/api/${encodeURIComponent(site.domain)}/complete-onboarding`, { + method: 'PUT', + body: {} + }) + .catch((error) => { + if (!(error instanceof api.ApiError)) { + throw error + } + }) } return ( @@ -114,8 +58,8 @@ export function EmailReportsCTABanner() { {' '} setVisible(false)} > Get weekly traffic reports by email → diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index 44f67dc1ff55..d4668180756a 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -62,7 +62,8 @@ describe('parseSiteFromDataset', () => { isDbip: false, flags: {}, shared: false, - isConsolidatedView: false + isConsolidatedView: false, + showEmailReportsCta: false } it('parses from dom string map correctly', () => { diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index c812686a0ab6..e2f2d67556b8 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -28,7 +28,8 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { isDbip: dataset.isDbip === 'true', flags: JSON.parse(dataset.flags!), shared: !!dataset.sharedLinkAuth, - isConsolidatedView: dataset.isConsolidatedView === 'true' + isConsolidatedView: dataset.isConsolidatedView === 'true', + showEmailReportsCta: dataset.showEmailReportsCta === 'true' } } @@ -62,7 +63,8 @@ export const siteContextDefaultValue = { isDbip: false, flags: {} as FeatureFlags, shared: false, - isConsolidatedView: false + isConsolidatedView: false, + showEmailReportsCta: false } export type PlausibleSite = typeof siteContextDefaultValue diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index 062aac19f3e5..8d25852950e1 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -50,7 +50,8 @@ export const DEFAULT_SITE: PlausibleSite = { isDbip: false, flags: {}, shared: false, - isConsolidatedView: false + isConsolidatedView: false, + showEmailReportsCta: false } export const TestContextProviders = ({ diff --git a/assets/test-utils/mock-api.ts b/assets/test-utils/mock-api.ts index 91a597eb2eff..b68b631a5ae4 100644 --- a/assets/test-utils/mock-api.ts +++ b/assets/test-utils/mock-api.ts @@ -35,6 +35,14 @@ export class MockAPI { return this.register('post', urlWithoutQueryString, responseHandler) } + // sets put handler + public put( + urlWithoutQueryString: string, + responseHandler: typeof fetch | Record + ): jest.Mock { + return this.register('put', urlWithoutQueryString, responseHandler) + } + private register( method: string, urlWithoutQueryString: string, diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 3b9e16dd8b84..0c26ba4d451b 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -408,6 +408,7 @@ defmodule Plausible.Sites do updated_site = site |> Site.set_stats_start_date(start_date) + |> Site.put_onboarding_status_advance(:first_pageview) |> Repo.update!() updated_site.stats_start_date diff --git a/lib/plausible_web/controllers/api/internal_controller.ex b/lib/plausible_web/controllers/api/internal_controller.ex index aa5e3b0c644b..05032e951d92 100644 --- a/lib/plausible_web/controllers/api/internal_controller.ex +++ b/lib/plausible_web/controllers/api/internal_controller.ex @@ -58,6 +58,24 @@ defmodule PlausibleWeb.Api.InternalController do end end + def complete_onboarding(conn, %{"domain" => domain}) do + with %User{} = user <- conn.assigns[:current_user], + site <- Sites.get_by_domain(domain), + true <- Teams.Memberships.has_editor_access?(site, user) do + site + |> Plausible.Site.put_onboarding_status_advance(:completed) + |> Repo.update!() + + json(conn, "ok") + else + _ -> + PlausibleWeb.Api.Helpers.unauthorized( + conn, + "You need to be logged in as the owner, admin, or editor of this site" + ) + end + end + defp sites_for(user, team) do from(u in subquery(Teams.Sites.accessible_by(user, team)), inner_join: s in ^Plausible.Site.regular(), diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex index 108891f486e1..791105cdab43 100644 --- a/lib/plausible_web/controllers/site_controller.ex +++ b/lib/plausible_web/controllers/site_controller.ex @@ -165,9 +165,15 @@ defmodule PlausibleWeb.SiteController do ) end - def settings_email_reports(conn, _params) do + def settings_email_reports(conn, params) do site = conn.assigns[:site] + if params["cta_clicked"] == "true" do + site + |> Plausible.Site.put_onboarding_status_advance(:completed) + |> Repo.update!() + end + conn |> render("settings_email_reports.html", site: site, diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 072050fe57f3..7a3124ff1aa0 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -94,6 +94,11 @@ defmodule PlausibleWeb.StatsController do PlausibleWeb.Flows.domain_change() ] or site.onboarding_status == :new_site) + show_email_reports_cta? = + not consolidated_view? and + site_role in [:owner, :admin, :editor] and + site.onboarding_status == :first_pageview + conn |> put_resp_header("x-robots-tag", "noindex, nofollow") |> render("stats.html", @@ -120,6 +125,7 @@ defmodule PlausibleWeb.StatsController do limited_to_segment_id: nil, connect_live_socket: verify_installation?, verify_installation?: verify_installation?, + show_email_reports_cta?: show_email_reports_cta?, verification_session: PlausibleWeb.Live.Components.VerificationBanner.query_params() |> Map.new(&{&1, conn.params[&1]}) @@ -434,6 +440,7 @@ defmodule PlausibleWeb.StatsController do team_identifier: team_identifier, limited_to_segment_id: limited_to_segment_id, verify_installation?: false, + show_email_reports_cta?: false, verification_session: %{} ) end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index e867512f66e4..f9c16cf88faa 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -412,6 +412,7 @@ defmodule PlausibleWeb.Router do get "/paddle/currency", Api.PaddleController, :currency put "/:domain/disable-feature", Api.InternalController, :disable_feature + put "/:domain/complete-onboarding", Api.InternalController, :complete_onboarding get "/sites", Api.InternalController, :sites end diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index cbd6602bd48b..2a308338f388 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -53,6 +53,7 @@ data-exploration-max-journey-steps={@exploration_max_journey_steps} data-team-identifier={@team_identifier} data-limited-to-segment-id={Jason.encode!(@limited_to_segment_id)} + data-show-email-reports-cta={to_string(@show_email_reports_cta?)} >
    <%= if @verify_installation? do %> diff --git a/test/plausible/sites_test.exs b/test/plausible/sites_test.exs index 5e9ea8db3cb6..326da0c215a2 100644 --- a/test/plausible/sites_test.exs +++ b/test/plausible/sites_test.exs @@ -173,6 +173,26 @@ defmodule Plausible.SitesTest do assert Repo.reload!(site).stats_start_date == Plausible.Times.today(site.timezone) end + test "advances onboarding_status to :first_pageview when stats are first discovered" do + site = insert(:site, onboarding_status: :new_site) + + populate_stats(site, [build(:pageview)]) + + Sites.stats_start_date(site) + + assert Repo.reload!(site).onboarding_status == :first_pageview + end + + test "does not regress :completed onboarding_status" do + site = insert(:site, onboarding_status: :completed, stats_start_date: nil) + + populate_stats(site, [build(:pageview)]) + + Sites.stats_start_date(site) + + assert Repo.reload!(site).onboarding_status == :completed + end + on_ee do test "resets consolidated view stats dates every time" do owner = new_user() diff --git a/test/plausible_web/controllers/api/internal_controller_test.exs b/test/plausible_web/controllers/api/internal_controller_test.exs index 7747efe1d896..b8e640a5aeb4 100644 --- a/test/plausible_web/controllers/api/internal_controller_test.exs +++ b/test/plausible_web/controllers/api/internal_controller_test.exs @@ -153,4 +153,78 @@ defmodule PlausibleWeb.Api.InternalControllerTest do assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain) end end + + describe "PUT /api/:domain/complete-onboarding" do + setup [:create_user, :log_in] + + test "when the logged-in user is the owner of the site", %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :first_pageview) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 200) == "ok" + assert %{onboarding_status: :completed} = Plausible.Sites.get_by_domain(site.domain) + end + + test "when the logged-in user is an editor guest of the site", %{conn: conn, user: user} do + site = new_site(onboarding_status: :first_pageview) + add_guest(site, user: user, role: :editor) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 200) == "ok" + assert %{onboarding_status: :completed} = Plausible.Sites.get_by_domain(site.domain) + end + + test "returns 401 when the logged-in user is a viewer of the site", %{conn: conn, user: user} do + site = new_site(onboarding_status: :first_pageview) + add_guest(site, user: user, role: :viewer) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner, admin, or editor of this site" + } + + assert %{onboarding_status: :first_pageview} = Plausible.Sites.get_by_domain(site.domain) + end + + test "returns 401 when the logged-in user doesn't have site access at all", %{conn: conn} do + site = new_site(onboarding_status: :first_pageview) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner, admin, or editor of this site" + } + + assert %{onboarding_status: :first_pageview} = Plausible.Sites.get_by_domain(site.domain) + end + + test "is idempotent - calling it again on an already-completed site is still a 200", %{ + conn: conn, + user: user + } do + site = new_site(owner: user, onboarding_status: :completed) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 200) == "ok" + assert %{onboarding_status: :completed} = Plausible.Sites.get_by_domain(site.domain) + end + end + + describe "PUT /api/:domain/complete-onboarding - user not logged in" do + test "returns 401 unauthorized", %{conn: conn} do + site = insert(:site, onboarding_status: :first_pageview) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner, admin, or editor of this site" + } + + assert %{onboarding_status: :first_pageview} = Plausible.Sites.get_by_domain(site.domain) + end + end end diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index 22407b15979e..8c66b55e5e8a 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -917,6 +917,46 @@ defmodule PlausibleWeb.SiteControllerTest do end end + describe "GET /:domain/settings/email-reports" do + setup [:create_user, :log_in, :create_site] + + test "renders the page without advancing onboarding_status by default", %{ + conn: conn, + site: site + } do + site = site |> Ecto.Changeset.change(onboarding_status: :first_pageview) |> Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/email-reports") + + assert html_response(conn, 200) =~ "Weekly email reports" + assert Repo.reload!(site).onboarding_status == :first_pageview + end + + test "advances onboarding_status to :completed when cta_clicked=true", %{ + conn: conn, + site: site + } do + site = site |> Ecto.Changeset.change(onboarding_status: :first_pageview) |> Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/email-reports?cta_clicked=true") + + assert html_response(conn, 200) + assert Repo.reload!(site).onboarding_status == :completed + end + + test "cta_clicked=true does not regress :completed onboarding_status", %{ + conn: conn, + site: site + } do + site = site |> Ecto.Changeset.change(onboarding_status: :completed) |> Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/email-reports?cta_clicked=true") + + assert html_response(conn, 200) + assert Repo.reload!(site).onboarding_status == :completed + end + end + describe "GET /:domain/settings/visibility" do setup [:create_user, :log_in, :create_site] diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 464d8ce613aa..8f31e5777793 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -129,6 +129,20 @@ defmodule PlausibleWeb.StatsControllerTest do refute element_exists?(resp, @verification_banner) end + test "public site - anonymous visitors never see the email reports CTA", %{conn: conn} do + public_site = + new_site( + domain: "some-other-public-site.io", + public: true, + onboarding_status: :first_pageview + ) + + resp = get(conn, "/#{public_site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + test "can not view stats of a private website", %{conn: conn} do _ = insert(:user) conn = get(conn, "/test-site.com") @@ -203,6 +217,64 @@ defmodule PlausibleWeb.StatsControllerTest do end end + test "shows email reports CTA when onboarding_status is :first_pageview", %{ + conn: conn, + user: user + } do + site = new_site(owner: user, onboarding_status: :first_pageview) + + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" + end + + for status <- [:new_site, :verification_succeeded, :completed] do + test "does not show email reports CTA when onboarding_status is #{status}", %{ + conn: conn, + user: user + } do + site = new_site(owner: user, onboarding_status: unquote(status)) + + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + end + + test "does not show email reports CTA for a viewer, since they can't reach the settings page it links to", + %{conn: conn, user: user} do + site = new_site(onboarding_status: :first_pageview) + add_guest(site, user: user, role: :viewer) + + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-current-user-role") == "viewer" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + + on_ee do + test "does not show email reports CTA for consolidated views", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + cv = user |> team_of() |> new_consolidated_view() + + # `onboarding_status` should always be :completed for + # consolidated views anyway but this test makes sure that + # stats_controller explicitly excludes email reports CTA + # for consolidated views too. + cv + |> Ecto.Changeset.change(%{onboarding_status: :first_pageview}) + |> Plausible.Repo.update!() + + resp = get(conn, "/#{cv.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + end + on_ee do test "can't see exploration funnel UI if funnels feature unavailable", %{ conn: conn, @@ -480,6 +552,28 @@ defmodule PlausibleWeb.StatsControllerTest do assert resp =~ "This dashboard is actually locked" end + test "does not show email reports CTA when viewing as a super admin without site membership", + %{conn: conn} do + site = new_site(onboarding_status: :first_pageview) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-current-user-role") == "super_admin" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + + test "still shows email reports CTA for a super admin who is also a real site member", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :first_pageview) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" + end + on_ee do test "shows CRM link to the site", %{conn: conn} do site = new_site() @@ -521,6 +615,18 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" end + test "never shows the email reports CTA, regardless of the site's onboarding_status", %{ + conn: conn + } do + site = new_site(onboarding_status: :first_pageview) + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/#{site.domain}/?auth=#{link.slug}") + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + test "if the shared link is limited to a segment, only that segment is stuffed into data-segments", %{ conn: conn From 5a4eff5743ba42eba6915ec28db12accfd42791e Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 17:05:39 +0100 Subject: [PATCH 31/43] fix verification banner dismiss button showing through the dashboard options menu --- lib/plausible_web/live/components/verification_banner.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/verification_banner.ex b/lib/plausible_web/live/components/verification_banner.ex index b40731c01db9..b4ee2d968a0e 100644 --- a/lib/plausible_web/live/components/verification_banner.ex +++ b/lib/plausible_web/live/components/verification_banner.ex @@ -42,7 +42,7 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do |> assign(:query_params, @query_params) ~H""" -
    +
    <.dismiss_button container_id={@container_id} query_params={@query_params} /> <.render_progress :if={not @finished?} message={@message} /> <.render_success From bddfec9959b9d94bf1f4447f4bb50cb48459e016 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 18:13:53 +0100 Subject: [PATCH 32/43] validate named result exists in MockScenarios.put --- .../verification/diagnostics.ex | 127 ++++++++++-------- .../verification/mock_scenarios.ex | 38 ++++-- .../support/dev/controllers/e2e_controller.ex | 12 +- 3 files changed, 102 insertions(+), 75 deletions(-) diff --git a/extra/lib/plausible/installation_support/verification/diagnostics.ex b/extra/lib/plausible/installation_support/verification/diagnostics.ex index 2e6eb1872404..68ce77220305 100644 --- a/extra/lib/plausible/installation_support/verification/diagnostics.ex +++ b/extra/lib/plausible/installation_support/verification/diagnostics.ex @@ -409,75 +409,86 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do } end + # Every result `interpret/3` can produce is named here, so that verification + # can be mocked (see `Plausible.InstallationSupport.Verification.ChecksMock`) + # by referring to the exact same result-construction code `interpret/3` + # itself uses - a scenario name can never silently drift from what real + # verification would have interpreted. + @spec named_results() :: %{atom() => (Keyword.t() -> Result.t())} + defp named_results do + %{ + success: fn _assigns -> success() end, + succeeds_only_after_cache_bust: fn _assigns -> + handled_error(@error_succeeds_only_after_cache_bust) + end, + csp_disallowed: fn _assigns -> handled_error(@error_csp_disallowed) end, + proxy_network_error: fn _assigns -> handled_error(@error_proxy_network_error) end, + plausible_network_error: fn _assigns -> handled_error(@error_plausible_network_error) end, + browserless_temporary: fn _assigns -> + unhandled_error(@error_browserless_temporary, browserless_issue: true) + end, + unexpected_domain: fn assigns -> + assigns + |> Keyword.fetch!(:installation_type) + |> error_unexpected_domain() + |> handled_error() + end, + plausible_not_found: fn assigns -> + assigns + |> Keyword.fetch!(:installation_type) + |> error_plausible_not_found() + |> handled_error() + end, + plausible_not_found_unhandled: fn assigns -> + assigns + |> Keyword.fetch!(:installation_type) + |> error_plausible_not_found() + |> unhandled_error() + end, + domain_not_found: fn assigns -> + @error_domain_not_found + |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) + |> struct!(data: %{offer_custom_url_input: true}) + end, + browserless_network_error: fn assigns -> + @error_browserless_network + |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) + |> struct!(data: %{offer_custom_url_input: true}) + end, + unexpected_page_response: fn assigns -> + @error_unexpected_page_response + |> handled_error( + attempted_url: Keyword.fetch!(assigns, :attempted_url), + page_response_status: Keyword.fetch!(assigns, :page_response_status) + ) + |> struct!(data: %{offer_custom_url_input: true}) + end + } + end + @doc """ Looks up a named interpretation result, optionally built from the given assigns (e.g. `attempted_url`, `installation_type`) - keys that don't need any just ignore them. - - Every result `interpret/3` can produce is named here, so that verification - can be mocked (see `Plausible.InstallationSupport.Verification.ChecksMock`) - by referring to the exact same result-construction code `interpret/3` - itself uses - a scenario name can never silently drift from what real - verification would have interpreted. """ @spec named_result!(atom()) :: Result.t() def named_result!(key), do: named_result!(key, []) @spec named_result!(atom(), Keyword.t()) :: Result.t() - def named_result!(:success, _assigns), do: success() - - def named_result!(:succeeds_only_after_cache_bust, _assigns), - do: handled_error(@error_succeeds_only_after_cache_bust) - - def named_result!(:csp_disallowed, _assigns), do: handled_error(@error_csp_disallowed) - def named_result!(:proxy_network_error, _assigns), do: handled_error(@error_proxy_network_error) - - def named_result!(:plausible_network_error, _assigns), - do: handled_error(@error_plausible_network_error) - - def named_result!(:browserless_temporary, _assigns), - do: unhandled_error(@error_browserless_temporary, browserless_issue: true) - - def named_result!(:unexpected_domain, assigns) do - Keyword.fetch!(assigns, :installation_type) - |> error_unexpected_domain() - |> handled_error() - end - - def named_result!(:plausible_not_found, assigns) do - Keyword.fetch!(assigns, :installation_type) - |> error_plausible_not_found() - |> handled_error() - end - - def named_result!(:plausible_not_found_unhandled, assigns) do - Keyword.fetch!(assigns, :installation_type) - |> error_plausible_not_found() - |> unhandled_error() - end - - def named_result!(:domain_not_found, assigns) do - @error_domain_not_found - |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) - |> struct!(data: %{offer_custom_url_input: true}) - end - - def named_result!(:browserless_network_error, assigns) do - @error_browserless_network - |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) - |> struct!(data: %{offer_custom_url_input: true}) + def named_result!(key, assigns) do + case Map.fetch(named_results(), key) do + {:ok, build_result} -> build_result.(assigns) + :error -> raise ArgumentError, "No interpretation result named #{inspect(key)}" + end end - def named_result!(:unexpected_page_response, assigns) do - @error_unexpected_page_response - |> handled_error( - attempted_url: Keyword.fetch!(assigns, :attempted_url), - page_response_status: Keyword.fetch!(assigns, :page_response_status) - ) - |> struct!(data: %{offer_custom_url_input: true}) - end + @doc "Returns every valid `named_result!/2` scenario key." + @spec named_scenario_keys() :: [atom()] + def named_scenario_keys, do: Map.keys(named_results()) - def named_result!(key, _assigns) do - raise ArgumentError, "No interpretation result named #{inspect(key)}" + @spec named_scenario_from_string(String.t()) :: {:ok, atom()} | :error + def named_scenario_from_string(string) when is_binary(string) do + named_scenario_keys() + |> Enum.find_value(:error, fn key -> if Atom.to_string(key) == string, do: {:ok, key} end) end end diff --git a/extra/lib/plausible/installation_support/verification/mock_scenarios.ex b/extra/lib/plausible/installation_support/verification/mock_scenarios.ex index 2c3d9e76d7f8..c72408d10510 100644 --- a/extra/lib/plausible/installation_support/verification/mock_scenarios.ex +++ b/extra/lib/plausible/installation_support/verification/mock_scenarios.ex @@ -9,6 +9,8 @@ defmodule Plausible.InstallationSupport.Verification.MockScenarios do use GenServer + alias Plausible.InstallationSupport.Verification.Diagnostics + @type scenario :: %{ interpretation_result: atom(), slowdown: non_neg_integer() | nil, @@ -22,23 +24,37 @@ defmodule Plausible.InstallationSupport.Verification.MockScenarios do @doc """ Registers a mock verification for `domain`. - The `key` must be an atom that's recognized by - `Plausible.InstallationSupport.Verification.Diagnostics.named_result!/2`. + `key` (an atom or a string) must name a scenario recognized by + `Diagnostics.named_result!/2` - see `Diagnostics.named_scenario_keys/0`. + Returns `{:error, :unknown_scenario}` otherwise. ### Opts * `:slowdown` - overrides the check pipeline's default per-check delay * `:launch_delay` - overrides the delay before the first check starts """ - @spec put(String.t(), atom(), Keyword.t()) :: :ok - def put(domain, key, opts \\ []) when is_binary(domain) and is_atom(key) do - scenario = %{ - interpretation_result: key, - slowdown: Keyword.get(opts, :slowdown), - launch_delay: Keyword.get(opts, :launch_delay) - } - - GenServer.call(__MODULE__, {:put, domain, scenario}) + @spec put(String.t(), atom() | String.t(), Keyword.t()) :: :ok | {:error, :unknown_scenario} + def put(domain, key, opts \\ []) when is_binary(domain) do + with {:ok, key} <- resolve_key(key) do + scenario = %{ + interpretation_result: key, + slowdown: Keyword.get(opts, :slowdown), + launch_delay: Keyword.get(opts, :launch_delay) + } + + GenServer.call(__MODULE__, {:put, domain, scenario}) + end + end + + defp resolve_key(key) when is_atom(key) do + if key in Diagnostics.named_scenario_keys(), do: {:ok, key}, else: {:error, :unknown_scenario} + end + + defp resolve_key(key) when is_binary(key) do + case Diagnostics.named_scenario_from_string(key) do + {:ok, key} -> {:ok, key} + :error -> {:error, :unknown_scenario} + end end @doc "Returns the scenario registered for `domain`, or `nil` if none was set." diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex index 5682d9bff8e3..165992b82ee7 100644 --- a/test/support/dev/controllers/e2e_controller.ex +++ b/test/support/dev/controllers/e2e_controller.ex @@ -98,10 +98,6 @@ defmodule PlausibleWeb.E2EController do end def put_verification_scenario(conn, %{"domain" => domain, "scenario" => scenario} = params) do - # Using `String.to_atom/1` is safe here because this is test-only code - # routed only under Mix.env() == :e2e_test. - key = String.to_atom(scenario) - opts = [ slowdown: params["options"]["slowdown"] || 0, launch_delay: params["options"]["launch_delay"] || 0 @@ -114,9 +110,13 @@ defmodule PlausibleWeb.E2EController do rate_limit_key = "site_verification:#{domain}" :ets.select_delete(Plausible.RateLimit, [{{{rate_limit_key, :_}, :_, :_}, [], [true]}]) - :ok = Plausible.InstallationSupport.Verification.MockScenarios.put(domain, key, opts) + case Plausible.InstallationSupport.Verification.MockScenarios.put(domain, scenario, opts) do + :ok -> + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) - send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + {:error, :unknown_scenario} -> + send_resp(conn, 422, Jason.encode!(%{"error" => "Unknown scenario: #{scenario}"})) + end end defp get_goal(site, name) do From f638a19cfa8ecfa201012b19b0c0f62643b95f49 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 19:13:46 +0100 Subject: [PATCH 33/43] move ChecksMock and MockScenarios into a single file, use :ets --- .../verification/checks_mock.ex | 84 +++++++++++++++++-- .../verification/mock_scenarios.ex | 77 ----------------- 2 files changed, 79 insertions(+), 82 deletions(-) delete mode 100644 extra/lib/plausible/installation_support/verification/mock_scenarios.ex diff --git a/extra/lib/plausible/installation_support/verification/checks_mock.ex b/extra/lib/plausible/installation_support/verification/checks_mock.ex index 7650c56917cf..6f5273e12880 100644 --- a/extra/lib/plausible/installation_support/verification/checks_mock.ex +++ b/extra/lib/plausible/installation_support/verification/checks_mock.ex @@ -1,14 +1,88 @@ +defmodule Plausible.InstallationSupport.Verification.MockScenarios do + @moduledoc """ + Per-domain registry of forced verification outcomes. It's a public ETS + table owned by a simple GenServer process. Used to bypass the real DNS + lookup and browserless checks when iterating on verification banner UI + locally, or when driving it from Playwright e2e specs. + """ + + use GenServer + + alias Plausible.InstallationSupport.Verification.Diagnostics + + @table __MODULE__ + + @type scenario :: %{ + interpretation_result: atom(), + slowdown: non_neg_integer() | nil, + launch_delay: non_neg_integer() | nil + } + + def start_link(_opts) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(nil) do + :ets.new(@table, [:set, :public, :named_table, read_concurrency: true]) + {:ok, nil} + end + + @doc """ + Registers a mock verification for `domain`. + + `key` (an atom or a string) must name a scenario recognized by + `Diagnostics.named_result!/2` - see `Diagnostics.named_scenario_keys/0`. + Returns `{:error, :unknown_scenario}` otherwise. + + ### Opts + + * `:slowdown` - overrides the check pipeline's default per-check delay + * `:launch_delay` - overrides the delay before the first check starts + """ + @spec put(String.t(), atom() | String.t(), Keyword.t()) :: :ok | {:error, :unknown_scenario} + def put(domain, key, opts \\ []) when is_binary(domain) do + with {:ok, key} <- resolve_key(key) do + scenario = %{ + interpretation_result: key, + slowdown: Keyword.get(opts, :slowdown), + launch_delay: Keyword.get(opts, :launch_delay) + } + + :ets.insert(@table, {domain, scenario}) + :ok + end + end + + defp resolve_key(key) when is_atom(key) do + if key in Diagnostics.named_scenario_keys(), do: {:ok, key}, else: {:error, :unknown_scenario} + end + + defp resolve_key(key) when is_binary(key) do + case Diagnostics.named_scenario_from_string(key) do + {:ok, key} -> {:ok, key} + :error -> {:error, :unknown_scenario} + end + end + + @doc "Returns the scenario registered for `domain`, or `nil` if none was set." + @spec get(String.t()) :: scenario() | nil + def get(domain) when is_binary(domain) do + case :ets.lookup(@table, domain) do + [{^domain, scenario}] -> scenario + [] -> nil + end + end +end + defmodule Plausible.InstallationSupport.Verification.ChecksMock do @moduledoc """ Drop-in replacement for `Plausible.InstallationSupport.Verification.Checks` that never performs a real DNS lookup or browserless check for a domain with a registered mock scenario. Used locally (`:dev`) and in Playwright - e2e specs (`:e2e_test`) to deterministically drive - `PlausibleWeb.Live.Verification`'s banner UI - see - `Plausible.InstallationSupport.verification_checks_mod/0`. + e2e specs (`:e2e_test`) to deterministically drive Verification banner UI. - When no scenario is registered for a domain (see - `Plausible.InstallationSupport.Verification.MockScenarios.put/3`): + When no scenario is registered for a domain: * in `:dev`, falls back to the real `Checks` module - casually loading a site with `?verify_installation=true` still verifies for real unless diff --git a/extra/lib/plausible/installation_support/verification/mock_scenarios.ex b/extra/lib/plausible/installation_support/verification/mock_scenarios.ex deleted file mode 100644 index c72408d10510..000000000000 --- a/extra/lib/plausible/installation_support/verification/mock_scenarios.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule Plausible.InstallationSupport.Verification.MockScenarios do - @moduledoc """ - Per-domain registry of forced verification outcomes. - - Used to bypass the real DNS lookup and browserless check when iterating - on `PlausibleWeb.Live.Verification`'s banner UI locally, or when driving - it from Playwright e2e specs. - """ - - use GenServer - - alias Plausible.InstallationSupport.Verification.Diagnostics - - @type scenario :: %{ - interpretation_result: atom(), - slowdown: non_neg_integer() | nil, - launch_delay: non_neg_integer() | nil - } - - def start_link(_opts) do - GenServer.start_link(__MODULE__, %{}, name: __MODULE__) - end - - @doc """ - Registers a mock verification for `domain`. - - `key` (an atom or a string) must name a scenario recognized by - `Diagnostics.named_result!/2` - see `Diagnostics.named_scenario_keys/0`. - Returns `{:error, :unknown_scenario}` otherwise. - - ### Opts - - * `:slowdown` - overrides the check pipeline's default per-check delay - * `:launch_delay` - overrides the delay before the first check starts - """ - @spec put(String.t(), atom() | String.t(), Keyword.t()) :: :ok | {:error, :unknown_scenario} - def put(domain, key, opts \\ []) when is_binary(domain) do - with {:ok, key} <- resolve_key(key) do - scenario = %{ - interpretation_result: key, - slowdown: Keyword.get(opts, :slowdown), - launch_delay: Keyword.get(opts, :launch_delay) - } - - GenServer.call(__MODULE__, {:put, domain, scenario}) - end - end - - defp resolve_key(key) when is_atom(key) do - if key in Diagnostics.named_scenario_keys(), do: {:ok, key}, else: {:error, :unknown_scenario} - end - - defp resolve_key(key) when is_binary(key) do - case Diagnostics.named_scenario_from_string(key) do - {:ok, key} -> {:ok, key} - :error -> {:error, :unknown_scenario} - end - end - - @doc "Returns the scenario registered for `domain`, or `nil` if none was set." - @spec get(String.t()) :: scenario() | nil - def get(domain) when is_binary(domain) do - GenServer.call(__MODULE__, {:get, domain}) - end - - @impl true - def init(state), do: {:ok, state} - - @impl true - def handle_call({:put, domain, scenario}, _from, state) do - {:reply, :ok, Map.put(state, domain, scenario)} - end - - def handle_call({:get, domain}, _from, state) do - {:reply, Map.get(state, domain), state} - end -end From 3126a39ae7d147962d19345a870abd932e958819 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 19:57:11 +0100 Subject: [PATCH 34/43] move checks_mock.ex to test/support --- config/config.exs | 3 +- config/dev.exs | 4 +- config/e2e_test.exs | 3 +- .../installation_support.ex | 8 +- .../verification/checks_mock.ex | 197 ---------------- .../verification/checks_mock.ex | 212 ++++++++++++++++++ 6 files changed, 220 insertions(+), 207 deletions(-) delete mode 100644 extra/lib/plausible/installation_support/verification/checks_mock.ex create mode 100644 test/support/installation_support/verification/checks_mock.ex diff --git a/config/config.exs b/config/config.exs index 0b6efb948791..1c2199c0c1a8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -44,7 +44,8 @@ config :ref_inspector, config :plausible, paddle_api: Plausible.Billing.PaddleApi, - google_api: Plausible.Google.API + google_api: Plausible.Google.API, + verification_checks_mod: Plausible.InstallationSupport.Verification.Checks config :plausible, # 30 minutes diff --git a/config/dev.exs b/config/dev.exs index 7201157eea5b..f0851f421c41 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -25,7 +25,9 @@ config :plausible, PlausibleWeb.Endpoint, ] ] -config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock +config :plausible, + paddle_api: Plausible.Billing.DevPaddleApiMock, + verification_checks_mod: Plausible.InstallationSupport.Verification.ChecksMock config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime diff --git a/config/e2e_test.exs b/config/e2e_test.exs index c881cdec49cf..dada94766e68 100644 --- a/config/e2e_test.exs +++ b/config/e2e_test.exs @@ -6,7 +6,8 @@ config :plausible, PlausibleWeb.Endpoint, config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock, - google_api: Plausible.Google.API.Mock + google_api: Plausible.Google.API.Mock, + verification_checks_mod: Plausible.InstallationSupport.Verification.ChecksMock config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime diff --git a/extra/lib/plausible/installation_support/installation_support.ex b/extra/lib/plausible/installation_support/installation_support.ex index c1320a62aa4a..0452fc5503c5 100644 --- a/extra/lib/plausible/installation_support/installation_support.ex +++ b/extra/lib/plausible/installation_support/installation_support.ex @@ -13,13 +13,7 @@ defmodule Plausible.InstallationSupport do "Plausible Verification Agent - if abused, contact support@plausible.io" end - def verification_checks_mod do - if Mix.env() in [:dev, :e2e_test] do - Plausible.InstallationSupport.Verification.ChecksMock - else - Plausible.InstallationSupport.Verification.Checks - end - end + def verification_checks_mod, do: Application.fetch_env!(:plausible, :verification_checks_mod) else def user_agent() do "Plausible Community Edition" diff --git a/extra/lib/plausible/installation_support/verification/checks_mock.ex b/extra/lib/plausible/installation_support/verification/checks_mock.ex deleted file mode 100644 index 6f5273e12880..000000000000 --- a/extra/lib/plausible/installation_support/verification/checks_mock.ex +++ /dev/null @@ -1,197 +0,0 @@ -defmodule Plausible.InstallationSupport.Verification.MockScenarios do - @moduledoc """ - Per-domain registry of forced verification outcomes. It's a public ETS - table owned by a simple GenServer process. Used to bypass the real DNS - lookup and browserless checks when iterating on verification banner UI - locally, or when driving it from Playwright e2e specs. - """ - - use GenServer - - alias Plausible.InstallationSupport.Verification.Diagnostics - - @table __MODULE__ - - @type scenario :: %{ - interpretation_result: atom(), - slowdown: non_neg_integer() | nil, - launch_delay: non_neg_integer() | nil - } - - def start_link(_opts) do - GenServer.start_link(__MODULE__, nil, name: __MODULE__) - end - - @impl true - def init(nil) do - :ets.new(@table, [:set, :public, :named_table, read_concurrency: true]) - {:ok, nil} - end - - @doc """ - Registers a mock verification for `domain`. - - `key` (an atom or a string) must name a scenario recognized by - `Diagnostics.named_result!/2` - see `Diagnostics.named_scenario_keys/0`. - Returns `{:error, :unknown_scenario}` otherwise. - - ### Opts - - * `:slowdown` - overrides the check pipeline's default per-check delay - * `:launch_delay` - overrides the delay before the first check starts - """ - @spec put(String.t(), atom() | String.t(), Keyword.t()) :: :ok | {:error, :unknown_scenario} - def put(domain, key, opts \\ []) when is_binary(domain) do - with {:ok, key} <- resolve_key(key) do - scenario = %{ - interpretation_result: key, - slowdown: Keyword.get(opts, :slowdown), - launch_delay: Keyword.get(opts, :launch_delay) - } - - :ets.insert(@table, {domain, scenario}) - :ok - end - end - - defp resolve_key(key) when is_atom(key) do - if key in Diagnostics.named_scenario_keys(), do: {:ok, key}, else: {:error, :unknown_scenario} - end - - defp resolve_key(key) when is_binary(key) do - case Diagnostics.named_scenario_from_string(key) do - {:ok, key} -> {:ok, key} - :error -> {:error, :unknown_scenario} - end - end - - @doc "Returns the scenario registered for `domain`, or `nil` if none was set." - @spec get(String.t()) :: scenario() | nil - def get(domain) when is_binary(domain) do - case :ets.lookup(@table, domain) do - [{^domain, scenario}] -> scenario - [] -> nil - end - end -end - -defmodule Plausible.InstallationSupport.Verification.ChecksMock do - @moduledoc """ - Drop-in replacement for `Plausible.InstallationSupport.Verification.Checks` - that never performs a real DNS lookup or browserless check for a domain - with a registered mock scenario. Used locally (`:dev`) and in Playwright - e2e specs (`:e2e_test`) to deterministically drive Verification banner UI. - - When no scenario is registered for a domain: - - * in `:dev`, falls back to the real `Checks` module - casually loading a - site with `?verify_installation=true` still verifies for real unless - you've deliberately mocked that domain. - - * everywhere else (`:e2e_test`, and `:test` for this module's own - tests), raises - every e2e spec that drives verification is expected - to register a scenario before triggering it, and it shouldn't - silently fall back to a real, slow, non-deterministic check. - """ - - alias Plausible.InstallationSupport.{State, CheckRunner, Checks} - alias Plausible.InstallationSupport.Verification.{Diagnostics, MockScenarios} - alias Plausible.InstallationSupport.Verification.Checks, as: RealChecks - - defmodule FakeUrlCheck do - @moduledoc false - use Plausible.InstallationSupport.Check - - @impl true - def report_progress_as, do: Checks.Url.report_progress_as() - - @impl true - def perform(state, _opts), do: state - end - - defmodule FakeVerifyInstallationCheck do - @moduledoc false - use Plausible.InstallationSupport.Check - - @impl true - def report_progress_as, do: Checks.VerifyInstallation.report_progress_as() - - @impl true - def perform(state, _opts), do: state - end - - defmodule FakeVerifyInstallationCacheBustCheck do - @moduledoc false - use Plausible.InstallationSupport.Check - - @impl true - def report_progress_as, do: Checks.VerifyInstallationCacheBust.report_progress_as() - - @impl true - def perform(state, _opts), do: state - end - - @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t() - def run(url, data_domain, installation_type, opts \\ []) do - case MockScenarios.get(data_domain) do - nil -> - raise_unless_dev_env!(data_domain) - RealChecks.run(url, data_domain, installation_type, opts) - - scenario -> - run_mocked(url, data_domain, installation_type, opts, scenario) - end - end - - defp run_mocked(url, data_domain, installation_type, opts, scenario) do - report_to = Keyword.get(opts, :report_to, self()) - async? = Keyword.get(opts, :async?, true) - slowdown = scenario.slowdown || Keyword.get(opts, :slowdown, 500) - launch_delay = scenario.launch_delay || Keyword.get(opts, :launch_delay, 500) - - init_state = %State{ - url: url || "https://#{data_domain}", - data_domain: data_domain, - report_to: report_to, - diagnostics: %Diagnostics{selected_installation_type: installation_type} - } - - checks = [ - {FakeUrlCheck, []}, - {FakeVerifyInstallationCheck, []}, - {FakeVerifyInstallationCacheBustCheck, []} - ] - - CheckRunner.run(init_state, checks, - async?: async?, - report_to: report_to, - slowdown: slowdown, - launch_delay: launch_delay - ) - end - - @spec interpret_diagnostics(State.t()) :: Plausible.InstallationSupport.Result.t() - def interpret_diagnostics(%State{data_domain: data_domain} = state) do - case MockScenarios.get(data_domain) do - nil -> - raise_unless_dev_env!(data_domain) - RealChecks.interpret_diagnostics(state) - - scenario -> - Diagnostics.named_result!(scenario.interpretation_result, - installation_type: state.diagnostics.selected_installation_type, - attempted_url: state.url, - page_response_status: 500 - ) - end - end - - defp raise_unless_dev_env!(data_domain) do - if Mix.env() != :dev do - raise """ - ChecksMock was used to verify #{inspect(data_domain)}, but no scenario \ - is registered for it. Call MockScenarios.put/3 first. - """ - end - end -end diff --git a/test/support/installation_support/verification/checks_mock.ex b/test/support/installation_support/verification/checks_mock.ex new file mode 100644 index 000000000000..b25a3ed2951f --- /dev/null +++ b/test/support/installation_support/verification/checks_mock.ex @@ -0,0 +1,212 @@ +# This file lives under `test/support` (rather than `extra/lib`, alongside +# the rest of the EE-only installation-support code it depends on - +# `Diagnostics`, `State`, `CheckRunner`, `Checks`, `Check`) so it's available +# in the `:dev` env too - see `Plausible.InstallationSupport.MockScenarios` +# and `ChecksMock`'s moduledocs. `test/support` also compiles under +# `:ce_test`/`:ce_dev`, where none of those EE-only dependencies exist, so +# both modules are wrapped in a single `on_ee` block - under CE builds, +# neither is defined at all, matching the fact that this whole feature +# (installation verification) doesn't exist there. +use Plausible + +on_ee do + defmodule Plausible.InstallationSupport.Verification.MockScenarios do + @moduledoc """ + Per-domain registry of forced verification outcomes. It's a public ETS + table owned by a simple GenServer process. Used to bypass the real DNS + lookup and browserless checks when iterating on verification banner UI + locally, or when driving it from Playwright e2e specs. + """ + + use GenServer + + alias Plausible.InstallationSupport.Verification.Diagnostics + + @table __MODULE__ + + @type scenario :: %{ + interpretation_result: atom(), + slowdown: non_neg_integer() | nil, + launch_delay: non_neg_integer() | nil + } + + def start_link(_opts) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(nil) do + :ets.new(@table, [:set, :public, :named_table, read_concurrency: true]) + {:ok, nil} + end + + @doc """ + Registers a mock verification for `domain`. + + `key` (an atom or a string) must name a scenario recognized by + `Diagnostics.named_result!/2` - see `Diagnostics.named_scenario_keys/0`. + Returns `{:error, :unknown_scenario}` otherwise. + + ### Opts + + * `:slowdown` - overrides the check pipeline's default per-check delay + * `:launch_delay` - overrides the delay before the first check starts + """ + @spec put(String.t(), atom() | String.t(), Keyword.t()) :: :ok | {:error, :unknown_scenario} + def put(domain, key, opts \\ []) when is_binary(domain) do + with {:ok, key} <- resolve_key(key) do + scenario = %{ + interpretation_result: key, + slowdown: Keyword.get(opts, :slowdown), + launch_delay: Keyword.get(opts, :launch_delay) + } + + :ets.insert(@table, {domain, scenario}) + :ok + end + end + + defp resolve_key(key) when is_atom(key) do + if key in Diagnostics.named_scenario_keys(), + do: {:ok, key}, + else: {:error, :unknown_scenario} + end + + defp resolve_key(key) when is_binary(key) do + case Diagnostics.named_scenario_from_string(key) do + {:ok, key} -> {:ok, key} + :error -> {:error, :unknown_scenario} + end + end + + @doc "Returns the scenario registered for `domain`, or `nil` if none was set." + @spec get(String.t()) :: scenario() | nil + def get(domain) when is_binary(domain) do + case :ets.lookup(@table, domain) do + [{^domain, scenario}] -> scenario + [] -> nil + end + end + end + + defmodule Plausible.InstallationSupport.Verification.ChecksMock do + @moduledoc """ + Drop-in replacement for `Plausible.InstallationSupport.Verification.Checks` + that never performs a real DNS lookup or browserless check for a domain + with a registered mock scenario. Used locally (`:dev`) and in Playwright + e2e specs (`:e2e_test`) to deterministically drive Verification banner UI. + + When no scenario is registered for a domain: + + * in `:dev`, falls back to the real `Checks` module - casually loading a + site with `?verify_installation=true` still verifies for real unless + you've deliberately mocked that domain. + + * everywhere else (`:e2e_test`, and `:test` for this module's own + tests), raises - every e2e spec that drives verification is expected + to register a scenario before triggering it, and it shouldn't + silently fall back to a real, slow, non-deterministic check. + """ + + alias Plausible.InstallationSupport.{State, CheckRunner, Checks} + alias Plausible.InstallationSupport.Verification.{Diagnostics, MockScenarios} + alias Plausible.InstallationSupport.Verification.Checks, as: RealChecks + + defmodule FakeUrlCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.Url.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + defmodule FakeVerifyInstallationCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.VerifyInstallation.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + defmodule FakeVerifyInstallationCacheBustCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.VerifyInstallationCacheBust.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t() + def run(url, data_domain, installation_type, opts \\ []) do + case MockScenarios.get(data_domain) do + nil -> + raise_unless_dev_env!(data_domain) + RealChecks.run(url, data_domain, installation_type, opts) + + scenario -> + run_mocked(url, data_domain, installation_type, opts, scenario) + end + end + + defp run_mocked(url, data_domain, installation_type, opts, scenario) do + report_to = Keyword.get(opts, :report_to, self()) + async? = Keyword.get(opts, :async?, true) + slowdown = scenario.slowdown || Keyword.get(opts, :slowdown, 500) + launch_delay = scenario.launch_delay || Keyword.get(opts, :launch_delay, 500) + + init_state = %State{ + url: url || "https://#{data_domain}", + data_domain: data_domain, + report_to: report_to, + diagnostics: %Diagnostics{selected_installation_type: installation_type} + } + + checks = [ + {FakeUrlCheck, []}, + {FakeVerifyInstallationCheck, []}, + {FakeVerifyInstallationCacheBustCheck, []} + ] + + CheckRunner.run(init_state, checks, + async?: async?, + report_to: report_to, + slowdown: slowdown, + launch_delay: launch_delay + ) + end + + @spec interpret_diagnostics(State.t()) :: Plausible.InstallationSupport.Result.t() + def interpret_diagnostics(%State{data_domain: data_domain} = state) do + case MockScenarios.get(data_domain) do + nil -> + raise_unless_dev_env!(data_domain) + RealChecks.interpret_diagnostics(state) + + scenario -> + Diagnostics.named_result!(scenario.interpretation_result, + installation_type: state.diagnostics.selected_installation_type, + attempted_url: state.url, + page_response_status: 500 + ) + end + end + + defp raise_unless_dev_env!(data_domain) do + if Mix.env() != :dev do + raise """ + ChecksMock was used to verify #{inspect(data_domain)}, but no scenario \ + is registered for it. Call MockScenarios.put/3 first. + """ + end + end + end +end From 0cb9ea2c6645757715c0c729b46d7d7bd55c655b Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Jul 2026 20:44:40 +0100 Subject: [PATCH 35/43] different success message depending on the flow --- .../live/components/verification_banner.ex | 18 ++++++++++- test/plausible_web/live/verification_test.exs | 31 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/verification_banner.ex b/lib/plausible_web/live/components/verification_banner.ex index b4ee2d968a0e..dfb09f50bc77 100644 --- a/lib/plausible_web/live/components/verification_banner.ex +++ b/lib/plausible_web/live/components/verification_banner.ex @@ -48,6 +48,7 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do <.render_success :if={@finished? and @success?} domain={@domain} + flow={@flow} super_admin?={@super_admin?} verification_state={@verification_state} /> @@ -119,6 +120,8 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do end defp render_success(assigns) do + assigns = assign(assigns, :success_message, success_message(assigns.flow)) + ~H""" <.notice title="Tracking is active on your site" @@ -130,7 +133,7 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do

    - Your dashboard is ready. Data will appear here as soon as visitors start arriving. + {@success_message}

    <.super_admin_diagnostics :if={@super_admin? and not is_nil(@verification_state)} @@ -200,6 +203,19 @@ defmodule PlausibleWeb.Live.Components.VerificationBanner do """ end + defp success_message(flow) do + cond do + flow == PlausibleWeb.Flows.review() -> + "Visitors are being counted correctly." + + flow == PlausibleWeb.Flows.domain_change() -> + "Visitors are being counted correctly on your new domain." + + true -> + "Your dashboard is ready. Data will appear here as soon as visitors start arriving." + end + end + defp offer_custom_url_input?(interpretation) do match?(%{data: %{offer_custom_url_input: true}}, interpretation) end diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 72240e4ae996..61c15140b354 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -139,6 +139,37 @@ defmodule PlausibleWeb.Live.VerificationTest do end) end + for {flow, message} <- %{ + PlausibleWeb.Flows.review() => "Visitors are being counted correctly.", + PlausibleWeb.Flows.domain_change() => + "Visitors are being counted correctly on your new domain." + } do + @tag :ee_only + test "shows a flow-specific success message for flow=#{flow}", %{conn: conn, site: site} do + stub_dns() + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site, unquote(flow)) + + assert eventually(fn -> + html = render(lv) + {html =~ unquote(message), html} + end) + end + end + @tag :ee_only test "advances onboarding_status to :verification_succeeded on success", %{ conn: conn, From 25ece8abe005853c808e39302e7e020444137d11 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 29 Jul 2026 09:10:46 +0100 Subject: [PATCH 36/43] fixup: make sure component goes into loading state instantly after clicking retry --- extra/lib/plausible_web/live/verification.ex | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 920388f5a66e..80b95de3f05c 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -98,13 +98,15 @@ defmodule PlausibleWeb.Live.Verification do end def handle_event("launch-verification", _, socket) do + reset_component(socket) start_verification(socket) - {:noreply, reset_component(socket)} + {:noreply, socket} end def handle_event("retry", _, socket) do + reset_component(socket) start_verification(socket) - {:noreply, reset_component(socket)} + {:noreply, socket} end def handle_event("show-custom-url-form", _, socket) do @@ -125,8 +127,9 @@ defmodule PlausibleWeb.Live.Verification do |> assign(url_to_verify: custom_url) |> assign(custom_url_input?: false) + reset_component(socket) start_verification(socket) - {:noreply, reset_component(socket)} + {:noreply, socket} end def handle_info({:start, report_to}, socket) do From 9fc6b5fc6a4ca78e49f754d815e6388f4fa94fdb Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 29 Jul 2026 11:03:59 +0100 Subject: [PATCH 37/43] stats_start_date -> ensure_stats_start_date ...and return the whole site struct. StatsController.stats action needs the whole site returned to known the "just updated" onboarding status when rendering the dashboard stats.html. --- lib/plausible/purge.ex | 2 +- lib/plausible/sites.ex | 37 ++++++++++--------- .../stats/legacy/legacy_query_builder.ex | 2 +- lib/plausible/stats/query_period.ex | 2 +- .../controllers/stats_controller.ex | 16 ++++++-- test/plausible/sites_test.exs | 23 +++++++----- .../controllers/stats_controller_test.exs | 29 ++++++++++++++- test/workers/import_analytics_test.exs | 5 ++- 8 files changed, 80 insertions(+), 36 deletions(-) diff --git a/lib/plausible/purge.ex b/lib/plausible/purge.ex index aa072bade88a..6e4b56f159de 100644 --- a/lib/plausible/purge.ex +++ b/lib/plausible/purge.ex @@ -40,7 +40,7 @@ defmodule Plausible.Purge do Deletes imported stats from and clears the `stats_start_date` field. The `stats_start_date` is expected to get repopulated the next time - `Plausible.Sites.stats_start_date/1` is called. + `Plausible.Sites.ensure_stats_start_date/1` is called. If the input argument is a site, all imported stats are deleted. If it's a site import, only imported stats for that import are deleted. diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 0c26ba4d451b..d936a883a802 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -366,36 +366,38 @@ defmodule Plausible.Sites do |> Plausible.Repo.update!() end - @spec stats_start_date(Site.t()) :: Date.t() | nil + @spec ensure_stats_start_date(Site.t()) :: Site.t() @doc """ - Returns the date of the first event of the given site, or `nil` if the site - does not have stats yet. + Ensures `stats_start_date` is set on the given site, returning the + (possibly updated) site. `stats_start_date` stays `nil` if the site does + not have stats yet. If this is the first time the function is called for the site, it queries imported stats and Clickhouse, choosing the earliest start date and saves - it in the sites table. + it in the sites table - at the same time advancing `onboarding_status` to + `:first_pageview`, so callers see that reflected immediately rather than + needing to reload the site to notice it. """ - def stats_start_date(site) + def ensure_stats_start_date(site) on_ee do # for now, we're going to always update consolidated views, # though Repo.update! runs the actual update query only when # the value has changed - def stats_start_date(%Site{consolidated: true} = site) do + def ensure_stats_start_date(%Site{consolidated: true} = site) do team = Repo.preload(site, :team).team site |> Plausible.ConsolidatedView.change_stats_dates(team) |> Repo.update!() - |> Map.fetch!(:stats_start_date) end end - def stats_start_date(%Site{stats_start_date: %Date{} = date}) do - date + def ensure_stats_start_date(%Site{stats_start_date: %Date{}} = site) do + site end - def stats_start_date(%Site{} = site) do + def ensure_stats_start_date(%Site{} = site) do start_date = [ Plausible.Imported.earliest_import_start_date(site), @@ -405,13 +407,12 @@ defmodule Plausible.Sites do |> Enum.min(Date, fn -> nil end) if start_date do - updated_site = - site - |> Site.set_stats_start_date(start_date) - |> Site.put_onboarding_status_advance(:first_pageview) - |> Repo.update!() - - updated_site.stats_start_date + site + |> Site.set_stats_start_date(start_date) + |> Site.put_onboarding_status_advance(:first_pageview) + |> Repo.update!() + else + site end end @@ -421,7 +422,7 @@ defmodule Plausible.Sites do end def has_stats?(site) do - !!stats_start_date(site) + !!ensure_stats_start_date(site).stats_start_date end def create_shared_link(site, name, opts \\ []) do diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex index f942d27d1bcd..e79e3c4f7340 100644 --- a/lib/plausible/stats/legacy/legacy_query_builder.ex +++ b/lib/plausible/stats/legacy/legacy_query_builder.ex @@ -195,7 +195,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do defp put_input_date_range(query, site, %{"period" => "all"}) do today = today(query) - start_date = Plausible.Sites.stats_start_date(site) || today + start_date = Plausible.Sites.ensure_stats_start_date(site).stats_start_date || today datetime_range = DateTimeRange.new!(start_date, today, site.timezone) |> DateTimeRange.to_timezone("Etc/UTC") diff --git a/lib/plausible/stats/query_period.ex b/lib/plausible/stats/query_period.ex index 4b06b10ba5ab..6becdc0bd78d 100644 --- a/lib/plausible/stats/query_period.ex +++ b/lib/plausible/stats/query_period.ex @@ -61,7 +61,7 @@ defmodule Plausible.Stats.QueryPeriod do from a timezone alone. Other shapes pass through unchanged. """ def resolve_input_date_range(:all, %Plausible.Site{} = site, relative_date) do - start_date = Plausible.Sites.stats_start_date(site) || relative_date + start_date = Plausible.Sites.ensure_stats_start_date(site).stats_start_date || relative_date {:date_range, start_date, relative_date} end diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 7a3124ff1aa0..49df186b5ed9 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -47,10 +47,14 @@ defmodule PlausibleWeb.StatsController do plug(PlausibleWeb.Plugs.AuthorizeSiteAccess when action in [:stats]) def stats(%{assigns: %{site: site}} = conn, _params) do - site = Plausible.Repo.preload(site, :owners) + site = + site + |> Plausible.Repo.preload(:owners) + |> Plausible.Sites.ensure_stats_start_date() + site_role = conn.assigns[:site_role] current_user = conn.assigns[:current_user] - stats_start_date = Plausible.Sites.stats_start_date(site) + stats_start_date = site.stats_start_date can_see_stats? = not Teams.locked?(site.team) or site_role == :super_admin demo = site.domain == "plausible.io" dogfood_page_path = if demo, do: "/#{site.domain}", else: "/:dashboard" @@ -374,7 +378,13 @@ defmodule PlausibleWeb.StatsController do current_user = conn.assigns[:current_user] site_role = get_fallback_site_role(conn) shared_link = Plausible.Repo.preload(shared_link, :segment, site: [:owners]) - stats_start_date = Plausible.Sites.stats_start_date(shared_link.site) + + shared_link = %{ + shared_link + | site: Plausible.Sites.ensure_stats_start_date(shared_link.site) + } + + stats_start_date = shared_link.site.stats_start_date flags = get_flags(current_user, shared_link.site) diff --git a/test/plausible/sites_test.exs b/test/plausible/sites_test.exs index 326da0c215a2..8007f9f63867 100644 --- a/test/plausible/sites_test.exs +++ b/test/plausible/sites_test.exs @@ -143,11 +143,11 @@ defmodule Plausible.SitesTest do end end - describe "stats_start_date" do + describe "ensure_stats_start_date" do test "is nil if site has no stats" do site = insert(:site) - assert Sites.stats_start_date(site) == nil + assert Sites.ensure_stats_start_date(site).stats_start_date == nil end test "is date if site does have stats" do @@ -157,7 +157,8 @@ defmodule Plausible.SitesTest do build(:pageview) ]) - assert Sites.stats_start_date(site) == Plausible.Times.today(site.timezone) + assert Sites.ensure_stats_start_date(site).stats_start_date == + Plausible.Times.today(site.timezone) end test "memoizes value of start date" do @@ -169,17 +170,20 @@ defmodule Plausible.SitesTest do build(:pageview) ]) - assert Sites.stats_start_date(site) == Plausible.Times.today(site.timezone) + assert Sites.ensure_stats_start_date(site).stats_start_date == + Plausible.Times.today(site.timezone) + assert Repo.reload!(site).stats_start_date == Plausible.Times.today(site.timezone) end - test "advances onboarding_status to :first_pageview when stats are first discovered" do + test "advances onboarding_status to :first_pageview when stats are first discovered, in the returned site" do site = insert(:site, onboarding_status: :new_site) populate_stats(site, [build(:pageview)]) - Sites.stats_start_date(site) + updated_site = Sites.ensure_stats_start_date(site) + assert updated_site.onboarding_status == :first_pageview assert Repo.reload!(site).onboarding_status == :first_pageview end @@ -188,7 +192,7 @@ defmodule Plausible.SitesTest do populate_stats(site, [build(:pageview)]) - Sites.stats_start_date(site) + Sites.ensure_stats_start_date(site) assert Repo.reload!(site).onboarding_status == :completed end @@ -203,11 +207,12 @@ defmodule Plausible.SitesTest do consolidated_view = new_consolidated_view(team) assert consolidated_view.stats_start_date == ~D[2000-01-01] - assert Sites.stats_start_date(consolidated_view) == ~D[2000-01-01] + assert Sites.ensure_stats_start_date(consolidated_view).stats_start_date == ~D[2000-01-01] new_site(team: team, native_stats_start_at: ~N[1999-01-01 12:00:00]) - assert Sites.stats_start_date(consolidated_view) == ~D[1999-01-01] + assert Sites.ensure_stats_start_date(consolidated_view).stats_start_date == + ~D[1999-01-01] end end end diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 8f31e5777793..d6c249a5165f 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -228,6 +228,19 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" end + test "shows email reports CTA on the very first load that discovers a pageview, without needing a second refresh", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :verification_succeeded) + populate_stats(site, [build(:pageview)]) + + assert Repo.reload!(site).onboarding_status == :verification_succeeded + + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" + assert Repo.reload!(site).onboarding_status == :first_pageview + end + for status <- [:new_site, :verification_succeeded, :completed] do test "does not show email reports CTA when onboarding_status is #{status}", %{ conn: conn, @@ -511,7 +524,13 @@ defmodule PlausibleWeb.StatsControllerTest do populate_stats(site_with_stats, [build(:pageview)]) for site <- [site_without_stats, site_with_stats] do - resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + resp = + get( + conn, + "/#{site.domain}?verify_installation=true&flow=#{PlausibleWeb.Flows.review()}" + ) + |> html_response(200) + assert element_exists?(resp, @verification_banner) end end @@ -537,7 +556,13 @@ defmodule PlausibleWeb.StatsControllerTest do site_with_stats.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() for site <- [site_without_stats, site_with_stats] do - resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + resp = + get( + conn, + "/#{site.domain}?verify_installation=true&flow=#{PlausibleWeb.Flows.review()}" + ) + |> html_response(200) + assert element_exists?(resp, @verification_banner) end end diff --git a/test/workers/import_analytics_test.exs b/test/workers/import_analytics_test.exs index 42613cbaa4a6..4af13ff2b850 100644 --- a/test/workers/import_analytics_test.exs +++ b/test/workers/import_analytics_test.exs @@ -59,7 +59,10 @@ defmodule Plausible.Workers.ImportAnalyticsTest do site = Repo.reload!(site) assert site.stats_start_date == nil - assert Plausible.Sites.stats_start_date(site) == import_opts[:start_date] + + assert Plausible.Sites.ensure_stats_start_date(site).stats_start_date == + import_opts[:start_date] + assert Repo.reload!(site).stats_start_date == import_opts[:start_date] end From 3fd559361f30479c27df341cbbccd09c98a69f9a Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 29 Jul 2026 11:55:40 +0100 Subject: [PATCH 38/43] advance onboarding status from /sites page too --- lib/plausible_web/live/sites.ex | 24 ++++++++++++++++++++++-- test/plausible_web/live/sites_test.exs | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index 5f2201a5e4e9..e4d0c5ee07eb 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -7,6 +7,7 @@ defmodule PlausibleWeb.Live.Sites do import PlausibleWeb.Live.Components.Pagination import PlausibleWeb.StatsView, only: [large_number_format: 1] + alias Plausible.Repo alias Plausible.Sites alias Plausible.Sites.Index alias Plausible.Teams @@ -967,8 +968,6 @@ defmodule PlausibleWeb.Live.Sites do site_entries = Sites.get_for_user_by_ids(assigns.current_user, page.entries, team: assigns.current_team) - sites = %{page | entries: site_entries} - sparklines = if connected?(socket) do Plausible.Stats.Sparkline.parallel_overview(site_entries) @@ -976,6 +975,10 @@ defmodule PlausibleWeb.Live.Sites do %{} end + site_entries = Enum.map(site_entries, &advance_onboarding_status_if_needed(&1, sparklines)) + + sites = %{page | entries: site_entries} + consolidated_sparkline = if connected?(socket), do: load_consolidated_sparkline(assigns.consolidated_view), @@ -989,6 +992,23 @@ defmodule PlausibleWeb.Live.Sites do ) end + defp advance_onboarding_status_if_needed( + %Plausible.Site{onboarding_status: :new_site} = site, + sparklines + ) do + case Map.get(sparklines, site.domain) do + %{visitors: visitors} when visitors > 0 -> + site + |> Plausible.Site.put_onboarding_status_advance(:first_pageview) + |> Repo.update!() + + _ -> + site + end + end + + defp advance_onboarding_status_if_needed(site, _sparklines), do: site + defp refresh_index_pins(socket) do assign(socket, :index_state, Index.refresh_pins(socket.assigns.index_state)) end diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs index 0deeec9fbd6a..e78e9fbd5834 100644 --- a/test/plausible_web/live/sites_test.exs +++ b/test/plausible_web/live/sites_test.exs @@ -164,6 +164,25 @@ defmodule PlausibleWeb.Live.SitesTest do dashboard_link_href = text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") assert dashboard_link_href =~ "verify_installation=true" + + assert Repo.reload!(site).onboarding_status == :new_site + end + + @tag :ee_only + test "advances onboarding_status (and hides the badge) when the site already has visitors, before its dashboard has ever been loaded", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :new_site) + populate_stats(site, [build(:pageview)]) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + refute site_card =~ "Setup pending" + + dashboard_link_href = text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + refute dashboard_link_href =~ "verify_installation=true" + + assert Repo.reload!(site).onboarding_status == :first_pageview end for status <- [:verification_succeeded, :first_pageview, :completed] do From 11311817072aaf3a4cb4cc38236cc5f9a90f2d4d Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 29 Jul 2026 19:04:58 +0100 Subject: [PATCH 39/43] push_navigate -> redirect --- lib/plausible_web/live/installation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index 769949868796..f30816f3812d 100644 --- a/lib/plausible_web/live/installation.ex +++ b/lib/plausible_web/live/installation.ex @@ -333,7 +333,7 @@ defmodule PlausibleWeb.Live.Installation do Routes.stats_path(socket, :stats, domain, []) end - {:noreply, push_navigate(socket, to: destination)} + {:noreply, redirect(socket, to: destination)} end defp initialize_installation_data(flow, site, params) do From 1e9e35264d9c27d186d7199e361568285fdbb6a8 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 29 Jul 2026 19:07:50 +0100 Subject: [PATCH 40/43] changelog + change dashboard link text for CE on installation screen --- CHANGELOG.md | 1 + lib/plausible_web/live/installation.ex | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc3e1820877..471a0501c812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file. ### Removed +- Removed the intermediate "Awaiting your first pageview" screen after site created. User is now taken straight to the dashboard from the installation instructions page. - Removed the standalone team switcher page; team switching is now done from the topbar dropdown only - Removed `Bamboo.SMTPAdapter` from supported e-mail adapters; the library is no longer in active developments and does not compile under Elixir 1.20+ diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index f30816f3812d..83ffac86e35b 100644 --- a/lib/plausible_web/live/installation.ex +++ b/lib/plausible_web/live/installation.ex @@ -184,7 +184,7 @@ defmodule PlausibleWeb.Live.Installation do type="submit" class="w-full mt-8" > - {verify_cta(@installation_type.result)} + {submit_button_text(@installation_type.result)} @@ -203,10 +203,14 @@ defmodule PlausibleWeb.Live.Installation do """ end - defp verify_cta("manual"), do: "Verify Script installation" - defp verify_cta("wordpress"), do: "Verify WordPress installation" - defp verify_cta("gtm"), do: "Verify Tag Manager installation" - defp verify_cta("npm"), do: "Verify NPM installation" + on_ee do + defp submit_button_text("manual"), do: "Verify Script installation" + defp submit_button_text("wordpress"), do: "Verify WordPress installation" + defp submit_button_text("gtm"), do: "Verify Tag Manager installation" + defp submit_button_text("npm"), do: "Verify NPM installation" + else + defp submit_button_text(_), do: "Proceed to dashboard" + end on_ee do defp install_method_event(installation_type, recommended) do From 63331faf0a415346676fcee04d095c6cd5144b9e Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 30 Jul 2026 08:49:21 +0100 Subject: [PATCH 41/43] credo and remove redundant else clause --- lib/plausible_web/controllers/stats_controller.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 49df186b5ed9..3ff0d0125c4c 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -313,12 +313,10 @@ defmodule PlausibleWeb.StatsController do defp serialize_star_path_as_query_string_fragment(conn) do star_path = conn.path_params["path"] - if length(star_path) > 0 do + if star_path != [] do # make the path start with a / # to be able to reject values that don't start with a / %{"return_to" => "/#{Enum.join(star_path, "/")}"} |> URI.encode_query() - else - nil end end From 5a9d15906356a4bb78c01fcf2e56e7cf252d1afa Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 30 Jul 2026 10:00:39 +0100 Subject: [PATCH 42/43] fix installation_test (CE) --- test/plausible_web/live/installation_test.exs | 144 ++++++++++-------- 1 file changed, 84 insertions(+), 60 deletions(-) diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs index 8b199583ae72..794b6ec09f4d 100644 --- a/test/plausible_web/live/installation_test.exs +++ b/test/plausible_web/live/installation_test.exs @@ -8,6 +8,20 @@ defmodule PlausibleWeb.Live.InstallationTest do @migration_guide_link "https://plausible.io/docs/script-update-guide" + on_ee do + @manual_button_text "Verify Script installation" + @wordpress_button_text "Verify WordPress installation" + @gtm_button_text "Verify Tag Manager installation" + @npm_button_text "Verify NPM installation" + else + @shared_button_text "Proceed to dashboard" + + @manual_button_text @shared_button_text + @wordpress_button_text @shared_button_text + @gtm_button_text @shared_button_text + @npm_button_text @shared_button_text + end + setup [:create_user, :log_in, :create_site] describe "GET /:domain/installation" do @@ -43,7 +57,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert text(html) =~ "Verify WordPress installation" + assert text(html) =~ @wordpress_button_text end @tag :ee_only @@ -57,7 +71,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=wordpress") html = render_async(lv, 500) - assert text(html) =~ "Verify WordPress installation" + assert text(html) =~ @wordpress_button_text end @tag :ee_only @@ -71,7 +85,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=gtm") html = render_async(lv, 500) - assert text(html) =~ "Verify Tag Manager installation" + assert text(html) =~ @gtm_button_text end @tag :ee_only @@ -85,7 +99,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=npm") html = render_async(lv, 500) - assert text(html) =~ "Verify NPM installation" + assert text(html) =~ @npm_button_text end @tag :ee_only @@ -99,54 +113,64 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=manual") html = render_async(lv, 500) - assert text(html) =~ "Verify Script installation" + assert text(html) =~ @manual_button_text end - @tag :ee_only - test "allows switching between installation tabs (EE)", %{conn: conn, site: site} do - stub_dns() - stub_detection_manual() + on_ee do + test "allows switching between installation tabs (EE)", %{conn: conn, site: site} do + stub_dns() + stub_detection_manual() - {lv, _html} = get_lv(conn, site, "?type=manual") + {lv, _html} = get_lv(conn, site, "?type=manual") - html = render_async(lv, 500) - assert html =~ "Verify Script installation" + html = render_async(lv, 500) + assert html =~ @manual_button_text - lv - |> element("a[href*=\"type=wordpress\"]") - |> render_click() + lv + |> element("a[href*=\"type=wordpress\"]") + |> render_click() - html = render(lv) - assert html =~ "Verify WordPress installation" + html = render(lv) + assert html =~ @wordpress_button_text - lv - |> element("a[href*=\"type=gtm\"]") - |> render_click() + lv + |> element("a[href*=\"type=gtm\"]") + |> render_click() - html = render(lv) - assert html =~ "Verify Tag Manager installation" + html = render(lv) + assert html =~ @gtm_button_text - lv - |> element("a[href*=\"type=npm\"]") - |> render_click() + lv + |> element("a[href*=\"type=npm\"]") + |> render_click() - html = render(lv) - assert html =~ "Verify NPM installation" - end + html = render(lv) + assert html =~ @npm_button_text + end + else + test "allows switching between installation tabs (CE)", %{conn: conn, site: site} do + {lv, _html} = get_lv(conn, site) - @tag :ce_build_only - test "allows switching between installation tabs (CE)", %{conn: conn, site: site} do - {lv, _html} = get_lv(conn, site) + html = render_async(lv, 500) + assert html =~ "window.plausible" + assert html =~ @shared_button_text - html = render_async(lv, 500) - assert html =~ "Verify Script installation" + lv + |> element("a[href*=\"type=wordpress\"]") + |> render_click() - lv - |> element("a[href*=\"type=wordpress\"]") - |> render_click() + html = render(lv) + assert html =~ "https://plausible.io/wordpress-analytics-plugin" + assert html =~ @shared_button_text - html = render(lv) - assert html =~ "Verify WordPress installation" + lv + |> element("a[href*=\"type=npm\"]") + |> render_click() + + html = render(lv) + assert html =~ "@plausible-analytics/tracker" + assert html =~ @shared_button_text + end end test "manual installations has script snippet with expected ID", %{conn: conn, site: site} do @@ -159,7 +183,7 @@ defmodule PlausibleWeb.Live.InstallationTest do assert eventually(fn -> html = render(lv) - {html =~ "Verify Script installation", html} + {html =~ @manual_button_text, html} end) html = render(lv) @@ -178,7 +202,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text assert html =~ "Optional measurements" assert html =~ "Outbound links" assert html =~ "File downloads" @@ -194,7 +218,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text assert html =~ "Advanced options" assert html =~ "Manual tagging" assert html =~ "404 error pages" @@ -215,7 +239,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text config = TrackerScriptConfiguration |> Plausible.Repo.get_by!(site_id: site.id) assert config.outbound_links == true @@ -241,10 +265,10 @@ defmodule PlausibleWeb.Live.InstallationTest do on_ee do for {type, expected_text} <- [ - {"manual", "Verify Script installation"}, - {"wordpress", "Verify WordPress installation"}, - {"gtm", "Verify Tag Manager installation"}, - {"npm", "Verify NPM installation"} + {"manual", @manual_button_text}, + {"wordpress", @wordpress_button_text}, + {"gtm", @gtm_button_text}, + {"npm", @npm_button_text} ] do test "submitting form with #{type} redirects to the dashboard with the verification banner (EE)", %{ @@ -311,7 +335,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text # Test with all options disabled lv @@ -343,7 +367,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text lv |> element("form[phx-submit='submit']") @@ -404,7 +428,7 @@ defmodule PlausibleWeb.Live.InstallationTest do html = render_async(lv, 500) refute text(html) =~ "We've detected your website is using WordPress" - assert text(html) =~ "Verify Script installation" + assert text(html) =~ @manual_button_text end @tag :ee_only @@ -415,7 +439,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Tag Manager installation" + assert html =~ @gtm_button_text assert text(html) =~ "We've detected your website is using Google Tag Manager" end @@ -435,7 +459,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify NPM installation" + assert html =~ @npm_button_text end @tag :ee_only @@ -474,7 +498,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=wordpress") html = render_async(lv, 500) - assert html =~ "Verify WordPress installation" + assert html =~ @wordpress_button_text refute element_exists?(html, "a[href='#{@migration_guide_link}']") end @@ -491,7 +515,7 @@ defmodule PlausibleWeb.Live.InstallationTest do assert eventually(fn -> html = render(lv) # Should default to manual installation when detection returns {:error, _} - {html =~ "Verify Script installation", html} + {html =~ @manual_button_text, html} end) end) end @@ -509,7 +533,7 @@ defmodule PlausibleWeb.Live.InstallationTest do html = render_async(lv, 500) # Should default to manual installation when detection returns {:error, _} - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end) end end @@ -536,7 +560,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end test "allows editor access to installation page", %{conn: conn, user: user} do @@ -551,7 +575,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end end @@ -569,7 +593,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=invalid") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end test "falls back to provisioning flow when invalid flow parameter supplied", %{ @@ -584,7 +608,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?flow=invalid") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end end @@ -604,7 +628,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Tag Manager installation" + assert html =~ @gtm_button_text end end @@ -628,7 +652,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?flow=review") html = render_async(lv, 500) - assert html =~ "Verify WordPress installation" + assert html =~ @wordpress_button_text end end From 3e8c4b8374b83c8ad64443a8bea8dd2b2d4331e5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 30 Jul 2026 10:35:06 +0100 Subject: [PATCH 43/43] fix E2E test --- e2e/tests/dashboard/breakdowns.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts index eeb7b3413077..93e5bc2065f0 100644 --- a/e2e/tests/dashboard/breakdowns.spec.ts +++ b/e2e/tests/dashboard/breakdowns.spec.ts @@ -614,6 +614,7 @@ test('pages breakdown', async ({ page, request }) => { await test.step('exit pages modal with an event filter applied', async () => { await page.goto('/' + domain + '?f=is,page,/page1', { waitUntil: 'commit' }) + await report.getByTestId('report-end').scrollIntoViewIfNeeded() await detailsLink(report).click() await expect(