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..6a69fb9d0c39 --- /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 ( +
+ + 🎉 + + Your first pageview has landed! + {' '} + + Get weekly traffic reports by email → + +
+ ) +} diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index 1736f963d227..bb2f7ac6be1f 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -11,6 +11,8 @@ 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' +import { EmailReportsCTABanner } from './email-reports-cta-banner' function DashboardStats({ importedDataInView, @@ -21,7 +23,11 @@ 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 ( -
+
<>
{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 new file mode 100644 index 000000000000..19d482ecb111 --- /dev/null +++ b/assets/js/dashboard/verification/portal.tsx @@ -0,0 +1,50 @@ +import React, { useEffect } from 'react' +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.VerificationBanner.query_params/0 + */ + queryParams: string[] +} + +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/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() +}) 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/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/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/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/extra/lib/plausible/installation_support/verification/diagnostics.ex b/extra/lib/plausible/installation_support/verification/diagnostics.ex index 0773c07550f8..2e6eb1872404 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() @@ -81,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__{ @@ -98,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__{ @@ -116,22 +137,25 @@ 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!(%{ - 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( @@ -149,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 @@ -168,17 +192,21 @@ 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!(%{ 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__{ @@ -188,30 +216,27 @@ 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 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) 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!(%{ - 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( @@ -222,30 +247,26 @@ 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!(%{ - 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) 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!(%{ - 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( @@ -263,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( @@ -277,39 +299,39 @@ 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" @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 +342,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 +394,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 +405,79 @@ 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 + + @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/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 0fc148d3a2b0..1d9550a6b5da 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -1,21 +1,20 @@ 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 - import PlausibleWeb.Components.Generic + alias Plausible.InstallationSupport + alias Plausible.InstallationSupport.State - alias Plausible.InstallationSupport.{State, Verification} - - @component PlausibleWeb.Live.Components.Verification + @component PlausibleWeb.Live.Components.VerificationBanner @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 @@ -36,9 +35,9 @@ 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) - custom_url_input? = params["custom_url"] == "true" + tracker_script_configuration = + PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site) socket = assign(socket, @@ -46,20 +45,19 @@ defmodule PlausibleWeb.Live.Verification do site: site, super_admin?: super_admin?, domain: domain, - has_pageviews?: has_pageviews?, component: @component, - installation_type: get_installation_type(params, site), + tracker_script_configuration: tracker_script_configuration, 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, + dismissed?: false ) - if connected?(socket) and not custom_url_input? do + if connected?(socket) do launch_delayed(socket) end @@ -67,19 +65,32 @@ 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?} module={@component} - installation_type={@installation_type} domain={@domain} id="verification-standalone" attempts={@attempts} flow={@flow} - awaiting_first_pageview?={not @has_pageviews?} super_admin?={@super_admin?} + custom_url_input?={@custom_url_input?} + dismissed?={@dismissed?} /> """ end @@ -94,6 +105,18 @@ 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 + + # 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 @@ -121,10 +144,10 @@ defmodule PlausibleWeb.Live.Verification do end {:ok, pid} = - Verification.Checks.run( + InstallationSupport.verification_checks_mod().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 ) @@ -149,11 +172,7 @@ defmodule PlausibleWeb.Live.Verification do end 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 + interpretation = InstallationSupport.verification_checks_mod().interpret_diagnostics(state) update_component(socket, finished?: true, @@ -165,58 +184,18 @@ defmodule PlausibleWeb.Live.Verification do {:noreply, assign(socket, checks_pid: nil)} 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 - @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types() |> Enum.map(&String.to_atom/1) - defp get_installation_type(params, site) do - cond do - params["installation_type"] in PlausibleWeb.Tracker.supported_installation_types() -> - params["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 - 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) + PlausibleWeb.Tracker.fallback_installation_type() 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", @@ -238,51 +217,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 - - 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. -

-
-
- - -
- -
-
- - """ - 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/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/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/components/generic.ex b/lib/plausible_web/components/generic.ex index ffdf5277c06a..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}

@@ -850,6 +870,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/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/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 38e9169f66f9..d1cabbde2a1c 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,14 @@ 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: + PlausibleWeb.Live.Components.VerificationBanner.query_params() + |> Map.new(&{&1, conn.params[&1]}) + |> Map.put("domain", site.domain) ) - - !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 +428,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 deleted file mode 100644 index ef8113d0a0a8..000000000000 --- a/lib/plausible_web/live/components/verification.ex +++ /dev/null @@ -1,205 +0,0 @@ -defmodule PlausibleWeb.Live.Components.Verification do - @moduledoc """ - This component is responsible for rendering the verification progress - and diagnostics. - """ - use Phoenix.LiveComponent - use Plausible - - alias PlausibleWeb.Router.Helpers, as: Routes - alias Plausible.InstallationSupport.{State, Result} - - import PlausibleWeb.Components.Generic - - attr(:domain, :string, required: true) - - attr(:message, :string, - default: "We're visiting your site to ensure that everything is working" - ) - - attr(:super_admin?, :boolean, default: false) - attr(:finished?, :boolean, default: false) - attr(:success?, :boolean, default: false) - attr(:verification_state, State, default: nil) - attr(:interpretation, Result, default: nil) - attr(:attempts, :integer, default: 0) - attr(:flow, :string, default: "") - attr(:installation_type, :string, default: nil) - attr(:awaiting_first_pageview?, :boolean, default: false) - - def render(assigns) do - ~H""" -
- <.render_progress :if={not @finished?} message={@message} /> - <.render_success - :if={@finished? and @success?} - awaiting_first_pageview?={@awaiting_first_pageview?} - domain={@domain} - /> - <.render_failed - :if={@finished? and not @success?} - interpretation={@interpretation} - attempts={@attempts} - domain={@domain} - flow={@flow} - installation_type={@installation_type} - /> - <.render_super_admin_diagnostics - :if={not is_nil(@verification_state) && @super_admin? && @finished?} - verification_state={@verification_state} - /> -
- """ - end - - defp render_progress(assigns) do - ~H""" - <.focus_box> -
-
-
-
- <.title>Verifying your installation -

{@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 - - - """ - 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"> - Verify installation again - -
- <:footer> - <.focus_list> - <:item :if={ - @interpretation && is_map(@interpretation.data) && - @interpretation.data[:offer_custom_url_input] - }> - - 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 - - - - - - """ - end - - defp render_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)} - - - -
-
- - """ - end - - defp to_string_value(value) when is_binary(value), do: value - defp to_string_value(value), do: inspect(value) -end diff --git a/lib/plausible_web/live/components/verification_banner.ex b/lib/plausible_web/live/components/verification_banner.ex new file mode 100644 index 000000000000..b40731c01db9 --- /dev/null +++ b/lib/plausible_web/live/components/verification_banner.ex @@ -0,0 +1,333 @@ +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. + """ + use Phoenix.LiveComponent + use Plausible + + alias PlausibleWeb.Router.Helpers, as: Routes + alias Plausible.InstallationSupport.{State, Result} + + import PlausibleWeb.Components.Generic + import PlausibleWeb.Live.Components.Form + + @container_id "verification-ui" + + # 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) + + attr(:message, :string, + default: "We're visiting your site to ensure that everything is working" + ) + + attr(:super_admin?, :boolean, default: false) + attr(:finished?, :boolean, default: false) + attr(:success?, :boolean, default: false) + attr(:verification_state, State, default: nil) + attr(:interpretation, Result, default: nil) + attr(:attempts, :integer, default: 0) + attr(:flow, :string, default: "") + attr(:custom_url_input?, :boolean, default: false) + attr(:dismissed?, :boolean, default: false) + + def render(assigns) do + assigns = + assigns + |> 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?} + domain={@domain} + super_admin?={@super_admin?} + verification_state={@verification_state} + /> + <.render_failed + :if={@finished? and not @success?} + interpretation={@interpretation} + attempts={@attempts} + domain={@domain} + flow={@flow} + super_admin?={@super_admin?} + verification_state={@verification_state} + custom_url_input?={@custom_url_input?} + /> +
+ """ + 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={:indigo} + title_class="text-base font-semibold text-gray-900 dark:text-gray-100" + > + <:icon> + <.spinner class="mt-0.5 size-4.5" /> + +

+ {@message}... +

+ + """ + end + + defp render_success(assigns) do + ~H""" + <.notice + title="Tracking is active on your site" + theme={:green} + title_class="text-base font-semibold text-green-800 dark:text-green-300" + icon_class="!mt-0.5 size-5 text-green-800 dark:text-green-300" + > + <:icon> + + +

+ Your dashboard is ready. Data will appear here as soon as visitors start arriving. +

+ <.super_admin_diagnostics + :if={@super_admin? and not is_nil(@verification_state)} + verification_state={@verification_state} + /> + + """ + end + + defp render_failed(assigns) do + assigns = + assign( + assigns, + :offer_custom_url_input?, + offer_custom_url_input?(assigns.interpretation) + ) + + ~H""" + <.notice + title={ + if @interpretation, + do: List.first(@interpretation.errors), + else: "We couldn't verify your installation" + } + theme={:yellow} + show_icon={false} + title_class="text-base font-semibold text-yellow-800 dark:text-yellow-400" + > + <.recommendation + :if={@interpretation} + interpretation={@interpretation} + offer_custom_url_input?={@offer_custom_url_input?} + domain={@domain} + flow={@flow} + /> +
+ <.retry_form_or_button custom_url_input?={@custom_url_input?} domain={@domain} /> + <.button_link + :if={not @custom_url_input? and @offer_custom_url_input?} + mt?={false} + href="#" + phx-click="show-custom-url-form" + id="verify-custom-url-link" + theme="ghost" + size="sm" + class="hover:bg-gray-600/10 dark:hover:bg-white/10 hover:border-transparent dark:hover:border-transparent" + > + Try another URL + + <.button_link + :if={not @custom_url_input? and not @offer_custom_url_input?} + mt?={false} + href={Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, flow: @flow)} + theme="ghost" + size="sm" + class="hover:bg-gray-600/10 dark:hover:bg-white/10 hover:border-transparent dark:hover:border-transparent" + > + Review installation + +
+ <.contact_us_link :if={ee?() and @attempts >= 3} /> + <.super_admin_diagnostics + :if={@super_admin? and not is_nil(@verification_state)} + verification_state={@verification_state} + /> + + """ + end + + defp offer_custom_url_input?(interpretation) do + match?(%{data: %{offer_custom_url_input: true}}, interpretation) + end + + defp recommendation(assigns) do + recommendation = List.first(assigns.interpretation.recommendations) + main = render_recommendation(recommendation.text, recommendation.inline_links) + + body = + if assigns.offer_custom_url_input? do + [main, ". ", review_installation_link_sentence(assigns), "."] + else + [main, "."] + end + + assigns = assign(assigns, :body, body) + + ~H""" +

+ {@body} +

+ """ + end + + defp render_recommendation(text, inline_links) do + html = + Enum.reduce(inline_links, html_escape(text), fn %{text: link_text, href: href}, acc -> + String.replace(acc, link_text, link_markup(link_text, href), global: false) + end) + + Phoenix.HTML.raw(html) + end + + defp review_installation_link_sentence(assigns) do + review_installation_url = + Routes.site_path(PlausibleWeb.Endpoint, :installation, assigns.domain, flow: assigns.flow) + + render_recommendation("See your installation instructions again here", [ + %{text: "here", href: review_installation_url} + ]) + end + + defp link_markup(text, href) do + external_attrs = + if String.starts_with?(href, "http"), + do: ~s| target="_blank" rel="noopener noreferrer"|, + else: "" + + ~s|#{html_escape(text)}| + end + + defp html_escape(value) do + value |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + 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-64 h-[38px] dark:bg-white/15 dark:border-transparent" + placeholder={"https://#{@domain}"} + value={"https://#{@domain}"} + /> + <.button type="submit" mt?={false} theme="primary" size="sm"> + Verify URL + +
+ """ + end + + defp retry_form_or_button(assigns) do + ~H""" + <.button_link + mt?={false} + href="#" + phx-click="retry" + theme="secondary" + size="sm" + class="dark:bg-white/15 dark:hover:bg-white/20 dark:border-transparent" + > + Check again + + """ + end + + defp contact_us_link(assigns) do + ~H""" +

+ Need help? {Phoenix.HTML.raw(link_markup("Contact us", "https://plausible.io/contact"))} +

+ """ + end + + defp super_admin_diagnostics(assigns) do + ~H""" +
+

+ + 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 + + defp to_string_value(value) when is_binary(value), do: value + defp to_string_value(value), do: inspect(value) +end diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index 88a16ae2e742..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> @@ -321,21 +319,21 @@ 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) - {: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 + ) + else + 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/installation/instructions.ex b/lib/plausible_web/live/installation/instructions.ex index 31d7292c301c..9716d01ea8e7 100644 --- a/lib/plausible_web/live/installation/instructions.ex +++ b/lib/plausible_web/live/installation/instructions.ex @@ -3,6 +3,8 @@ defmodule PlausibleWeb.Live.Installation.Instructions do Instruction forms and components for the Installation module """ use PlausibleWeb, :component + import PlausibleWeb.Components.Generic + alias Plausible.Site.TrackerScriptConfiguration attr :tracker_script_configuration_form, :map, required: true @@ -21,11 +23,7 @@ defmodule PlausibleWeb.Live.Installation.Instructions do Once done, click the button below to verify your installation.
- <.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 diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index 11217bd370a1..ed6b72daa1e7 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" >
    @@ -567,7 +584,7 @@ defmodule PlausibleWeb.Live.Sites do
  • - <.site_stats sparkline={@sparkline} /> + <.site_stats sparkline={@sparkline} needs_verification?={@needs_verification?} />
    @@ -657,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""" @@ -685,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/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 6c1439f81f25..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 @@ -602,15 +603,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..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 %>
    + <%= 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/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 diff --git a/test/plausible/installation_support/verification/checks_test.exs b/test/plausible/installation_support/verification/checks_test.exs index 01e01e4d7910..7b8118c9aad7 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'll need to 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'll need to 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..21a065654faa --- /dev/null +++ b/test/plausible/installation_support/verification/diagnostics_test.exs @@ -0,0 +1,49 @@ +defmodule Plausible.InstallationSupport.Verification.DiagnosticsTest do + use ExUnit.Case, async: true + use Plausible + + 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 + + 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 +end 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 diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index bb76d271a853..22407b15979e 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -362,7 +362,7 @@ defmodule PlausibleWeb.SiteControllerTest do }) assert redirected_to(conn) == - "/#{URI.encode_www_form("éxample.com")}/installation?site_created=true&flow=" + "/#{URI.encode_www_form("éxample.com")}/installation?flow=" assert site = Repo.get_by(Plausible.Site, domain: "éxample.com") assert site.timezone == "Europe/London" @@ -480,7 +480,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert redirected_to(conn) == "/example.com/installation?flow=" assert Repo.get_by(Plausible.Site, domain: "example.com") end @@ -501,7 +501,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert redirected_to(conn) == "/example.com/installation?flow=" assert Plausible.Teams.Billing.site_usage(team) == 3 end @@ -515,7 +515,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert redirected_to(conn) == "/example.com/installation?flow=" assert Repo.get_by(Plausible.Site, domain: "example.com") end end @@ -555,7 +555,7 @@ defmodule PlausibleWeb.SiteControllerTest do }) assert redirected_to(conn) == - "/example.com%2Fsome_blog_site/installation?site_created=true&flow=" + "/example.com%2Fsome_blog_site/installation?flow=" end test "renders form again when it is a duplicate domain", %{conn: conn} do @@ -618,7 +618,7 @@ defmodule PlausibleWeb.SiteControllerTest do }) assert redirected_to(conn) == - "/example.com/installation?site_created=true&flow=" + "/example.com/installation?flow=" end for role <- [:owner, :admin, :editor] do diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 38155954a91d..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 @@ -104,28 +105,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 element_exists?(resp, @verification_banner) 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 element_exists?(resp, @verification_banner) end test "can not view stats of a private website", %{conn: conn} do @@ -147,15 +148,19 @@ 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" + 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 element_exists?(resp, @verification_banner) - resp = conn |> get("/" <> site.domain <> "?skip_to_dashboard=true") |> html_response(200) - assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + resp = get(conn, "/#{site.domain}?verify_installation=true") |> html_response(200) + + assert element_exists?(resp, @verification_banner) + end end on_ee do @@ -229,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", %{ @@ -334,9 +340,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", @@ -387,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)]) - conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) - assert html_response(conn, 200) =~ "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 @@ -405,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!() - conn = get(conn, conn |> get("/#{site.domain}") |> redirected_to()) - assert html_response(conn, 200) =~ "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 @@ -427,9 +443,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_banner_test.exs b/test/plausible_web/live/components/verification_banner_test.exs new file mode 100644 index 000000000000..22d638c9d794 --- /dev/null +++ b/test/plausible_web/live/components/verification_banner_test.exs @@ -0,0 +1,244 @@ +defmodule PlausibleWeb.Live.Components.VerificationBannerTest do + use PlausibleWeb.ConnCase, async: true + + on_ee do + import Phoenix.LiveViewTest, only: [render_component: 2] + + alias Plausible.InstallationSupport.{State, Verification} + + @moduletag :capture_log + + @component PlausibleWeb.Live.Components.VerificationBanner + @banner "#verification-ui" + @progress ~s|#verification-ui p#progress| + + @loading_spinner ~s|#{@banner} svg.animate-spin| + @check_circle ~s|#{@banner} #check-circle| + @recommendations ~s|#recommendation| + @super_admin_report ~s|#super-admin-report| + + test "renders initial state" do + html = render_component(@component, domain: "example.com") + assert element_exists?(html, @progress) + + assert text_of_element(html, @progress) == + "We're visiting your site to ensure that everything is working..." + + assert element_exists?(html, @loading_spinner) + refute element_exists?(html, @recommendations) + refute element_exists?(html, @check_circle) + refute element_exists?(html, @super_admin_report) + end + + 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 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 + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "https://example.com", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{service_error: %{code: :domain_not_found}} + }) + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation + ) + + assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) + 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", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{} + } + + interpretation = Verification.Checks.interpret_diagnostics(state) + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation, + verification_state: state, + super_admin?: true + ) + + assert element_exists?(html, @super_admin_report) + assert text_of_element(html, @super_admin_report) =~ "Plausible is on window: nil" + end + + test "hides pulsating circle when finished, shows check circle" do + html = + render_component(@component, + domain: "example.com", + success?: true, + finished?: true + ) + + refute element_exists?(html, @loading_spinner) + assert element_exists?(html, @check_circle) + end + + test "renders a progress message" do + html = render_component(@component, domain: "example.com", message: "Arbitrary message") + + assert text_of_element(html, @progress) == "Arbitrary message..." + end + + test "renders contact link on >=3 attempts" do + html = render_component(@component, domain: "example.com", attempts: 2, finished?: true) + 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 help?" + assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) + end + + 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", + diagnostics: %Verification.Diagnostics{ + plausible_is_on_window: false, + plausible_is_initialized: false, + service_error: %{code: :domain_not_found} + } + }) + + assert interpretation.data.offer_custom_url_input == true + + html = + render_component(@component, + domain: "example.com", + finished?: true, + success?: false, + interpretation: interpretation + ) + + 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, replacing Check again with the Verify URL submit button, and hides the secondary action" 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"]|) + refute html =~ "Review installation" + + assert text_of_element(html, ~s|form[phx-submit="verify-custom-url"] button[type="submit"]|) =~ + "Verify URL" + + 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 a Review installation ghost button on failure by default" do + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + flow: PlausibleWeb.Flows.review() + ) + + refute element_exists?(html, ~s|a[href="/example.com/settings/general"]|) + + assert element_exists?( + html, + ~s|a[href="/example.com/installation?flow=review"]| + ) + + assert html =~ "Review installation" + end + end +end diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs deleted file mode 100644 index 2645de32cc4e..000000000000 --- a/test/plausible_web/live/components/verification_test.exs +++ /dev/null @@ -1,162 +0,0 @@ -defmodule PlausibleWeb.Live.Components.VerificationTest do - use PlausibleWeb.ConnCase, async: true - - on_ee do - import Phoenix.LiveViewTest, only: [render_component: 2] - - alias Plausible.InstallationSupport.{State, Verification} - - @moduletag :capture_log - - @component PlausibleWeb.Live.Components.Verification - @progress ~s|#verification-ui p#progress| - - @pulsating_circle ~s|div#verification-ui div.pulsating-circle| - @check_circle ~s|div#verification-ui #check-circle| - @error_circle ~s|div#verification-ui #error-circle| - @recommendations ~s|#recommendation| - @super_admin_report ~s|#super-admin-report| - - test "renders initial state" do - html = render_component(@component, domain: "example.com") - assert element_exists?(html, @progress) - - 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" - 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 - html = render_component(@component, domain: "example.com", success?: false, finished?: true) - refute element_exists?(html, @pulsating_circle) - refute element_exists?(html, @check_circle) - refute element_exists?(html, @recommendations) - assert element_exists?(html, @error_circle) - end - - test "renders diagnostic interpretation" do - interpretation = - Verification.Checks.interpret_diagnostics(%State{ - url: "https://example.com", - data_domain: "example.com", - diagnostics: %Verification.Diagnostics{service_error: %{code: :domain_not_found}} - }) - - html = - render_component(@component, - domain: "example.com", - success?: false, - finished?: true, - interpretation: interpretation - ) - - assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) - assert recommendation =~ "check that the domain you entered is correct" - - refute element_exists?(html, @super_admin_report) - end - - test "renders super-admin report" do - state = %State{ - url: "https://example.com", - data_domain: "example.com", - diagnostics: %Verification.Diagnostics{} - } - - interpretation = Verification.Checks.interpret_diagnostics(state) - - html = - render_component(@component, - domain: "example.com", - success?: false, - finished?: true, - interpretation: interpretation, - verification_state: state, - super_admin?: true - ) - - assert element_exists?(html, @super_admin_report) - assert text_of_element(html, @super_admin_report) =~ "Plausible is on window: nil" - end - - test "hides pulsating circle when finished, shows check circle" do - html = - render_component(@component, - domain: "example.com", - success?: true, - finished?: true - ) - - refute element_exists?(html, @pulsating_circle) - assert element_exists?(html, @check_circle) - end - - test "renders a progress message" do - html = render_component(@component, domain: "example.com", message: "Arbitrary message") - - assert text_of_element(html, @progress) == "Arbitrary message" - end - - 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 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 element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) - end - - test "renders link to verify installation at a different URL" 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} - } - }) - - 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", - finished?: true, - success?: false, - 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" - end - - test "offers escape paths: settings and installation instructions on failure" do - html = - render_component(@component, - domain: "example.com", - success?: false, - finished?: true, - installation_type: "wordpress", - flow: PlausibleWeb.Flows.review() - ) - - assert element_exists?(html, ~s|a[href="/example.com/settings/general"]|) - - assert element_exists?( - html, - ~s|a[href="/example.com/installation?flow=review&installation_type=wordpress"]| - ) - end - end -end diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs index 84e379b321fa..8b199583ae72 100644 --- a/test/plausible_web/live/installation_test.exs +++ b/test/plausible_web/live/installation_test.exs @@ -246,11 +246,13 @@ 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_detection_manual() {lv, _html} = get_lv(conn, site, "?type=#{unquote(type)}") @@ -270,9 +272,9 @@ defmodule PlausibleWeb.Live.InstallationTest do assert_redirect( lv, - Routes.site_path(conn, :verification, site.domain, - flow: "provisioning", - installation_type: unquote(type) + Routes.stats_path(conn, :stats, site.domain, + verify_installation: true, + flow: "provisioning" ) ) end @@ -280,7 +282,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 +299,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 +330,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 +356,19 @@ 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" + ) ) - ) + 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..664d4ce5c021 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -9,33 +9,36 @@ 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| + @banner ~s|#verification-ui| + + @in_progress_text "Verifying your installation" 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) =~ "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 - 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 @@ -51,45 +54,61 @@ 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 :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 reveals an inline form next to the retry button, submitting kicks off a new run", + %{ + conn: conn, + site: site + } do stub_dns() stub_verification_result(%{ - "completed" => false, - "error" => %{"message" => "Error"} + "completed" => true, + "trackerIsInHtml" => false, + "plausibleIsOnWindow" => false, + "plausibleIsInitialized" => false }) - # Get liveview with ?custom_url=true query param - {:ok, lv, html} = - conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true") + {: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) + + html = lv |> render_click("show-custom-url-form") + + refute html =~ @in_progress_text + + refute element_exists?(html, @retry_button) + refute element_exists?(html, "#verify-custom-url-link") - verifying_installation_text = "Verifying your installation" + assert element_exists?( + html, + ~s|form[phx-submit="verify-custom-url"] input[name="custom_url"]| + ) - # Assert form is rendered instead of kicking off verification automatically - 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"}) + lv + |> element("form[phx-submit='verify-custom-url']") + |> 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" + assert eventually(fn -> + html = render(lv) + {html =~ @in_progress_text, html} + end) end @tag :ee_only @@ -113,25 +132,13 @@ defmodule PlausibleWeb.Live.VerificationTest do assert eventually(fn -> html = render(lv) - - { - text_of_element(html, @awaiting) =~ - "Awaiting your first pageview", - html - } + {html =~ "Tracking is active on your site", 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) - ]) - + test "the dismissed flag keeps the banner hidden even if a late update arrives while still connected", + %{conn: conn, site: site} do stub_dns() stub_verification_result(%{ @@ -149,95 +156,63 @@ defmodule PlausibleWeb.Live.VerificationTest do {: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) - - { - text(html) =~ "Success", - html - } + {html =~ "Tracking is active on your site", html} end) html = render(lv) - - refute text_of_element(html, @awaiting) =~ "Awaiting your first pageview" - refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}/") + assert class_of_element(html, @banner) =~ "hidden" end - test "will redirect when first pageview arrives", %{conn: conn, site: site} do + @tag :ee_only + test "dismissing tells the client to close the websocket connection", + %{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 - } + "completed" => false, + "error" => %{"message" => "Error"} }) {: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_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)]) + render_click(lv, "dismiss") - assert_redirect(lv, Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)) + assert_push_event(lv, "disconnect-liveview", %{}) 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} <- [ + {"Make sure you've copied the snippet to the head of your site, or verify your installation manually.", + "manual"}, + {"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.", + {"Make sure you've configured the GTM template correctly, or verify your installation manually.", + "gtm"}, + {"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 + {"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 @@ -257,45 +232,49 @@ 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) - 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 end - defp get_lv(conn, site, qs \\ nil) do - {:ok, lv, html} = conn |> no_slowdown() |> live("/#{site.domain}/verification#{qs}") + defp get_lv(conn, site) do + {:ok, lv, html} = + 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() |> live("/#{site.domain}/verification#{qs}") + conn |> no_slowdown() |> no_delay() |> as_live() |> live(verification_path(site)) {: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), do: "/#{site.domain}?verify_installation=true" + defp no_slowdown(conn) do Plug.Conn.put_private(conn, :slowdown, 0) 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