Clean up legacy verification code and script v2 flag (#5824)

* add module name to service_error when check times out

Otherwise, it can sometimes remain unclear in the diagnostics, whether
it was InstallationV2 or InstallationV2CacheBust that timed out.

* Remove duplicate timeout logic

The current production logs show two types of verification timeouts:

* service_error: "Unhandled Browserless response status: 408" (vast
  majority of cases)
* service_error: :timeout (only a few cases)

The latter happens when we hit the Req receive_timeout
(endpoint_timeout + 2s). I've seen Browserless not respect the timeout
param from time to time, so it's better to keep the timeout logic
"in-house" only.

* make service_error into a map with code and extra

* interpret temporary service errors

...but still consider them "unhandled" for telemetry, also notifying Sentry
and logging the warning.

* separate sentry messages (verification)

* make Verification.ChecksTest more DRY

* organize tests into describe blocks

* test verification telemetry and logging

* fix codespell

* get rid of legacy verification

* rename Checks.InstallationV2 -> Checks.VerifyInstallation

* delete Live.Installation and rename Live.InstallationV2 -> Live.Installation

* rename installationv2 (live) files as well

* delete old change-domain routes

Also rename current liveview modules and routes, removing the v2 suffix

* rename domain_change_v2 files, removing v2 suffix

* remove legacy JS verifier code

Also fix dockerignore and elixir.yml referencing a wrong priv path

* rename verification_v2_test -> verification_test

* remove v2 prefix from logs and sentry messages

* clean up duplicate external_sites_controller_test.exs tests

* remove flag

* fix typespec

* pass timeout as query param to Browserless too

* Fixup external sites controller test module (#5826)

* fix test description

---------

Co-authored-by: Artur Pata <artur.pata@gmail.com>
This commit is contained in:
RobertJoonas 2025-10-27 09:39:41 +00:00 committed by GitHub
parent ad2c8e8e39
commit a83b4f3583
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 1334 additions and 7063 deletions

View File

@ -59,7 +59,7 @@ npm-debug.log
# Auto-generated tracker files
/priv/tracker/js/*.js
/priv/tracker/verifier/
/priv/tracker/installation_support/
# Dializer
/priv/plts/*.plt

View File

@ -81,7 +81,7 @@ jobs:
_build
tracker/node_modules
priv/tracker/js
priv/tracker/verifier
priv/tracker/installation_support
${{ env.PERSISTENT_CACHE_DIR }}
key: ${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
@ -97,7 +97,7 @@ jobs:
- 'tracker/**'
- name: Check if tracker and verifier are built already
run: |
if [ -f priv/tracker/js/plausible-web.js ] && [ -f priv/tracker/verifier/verifier-v1.js ]; then
if [ -f priv/tracker/js/plausible-web.js ] && [ -f priv/tracker/installation_support/verifier.js ]; then
echo "HAS_BUILT_TRACKER=true" >> $GITHUB_ENV
else
echo "HAS_BUILT_TRACKER=false" >> $GITHUB_ENV

View File

@ -57,9 +57,9 @@ config :plausible, Plausible.InstallationSupport.Checks.Detection,
plug: {Req.Test, Plausible.InstallationSupport.Checks.Detection}
]
config :plausible, Plausible.InstallationSupport.Checks.InstallationV2,
config :plausible, Plausible.InstallationSupport.Checks.VerifyInstallation,
req_opts: [
plug: {Req.Test, Plausible.InstallationSupport.Checks.InstallationV2}
plug: {Req.Test, Plausible.InstallationSupport.Checks.VerifyInstallation}
]
config :plausible, Plausible.Session.Salts, interval: :timer.hours(1)

View File

@ -1,34 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.CSP do
@moduledoc """
Scans the Content Security Policy header to ensure that the Plausible domain is allowed.
See `Plausible.InstallationSupport.LegacyVerification.Checks` for the execution sequence.
"""
use Plausible.InstallationSupport.Check
@impl true
def report_progress_as, do: "We're visiting your site to ensure that everything is working"
@impl true
def perform(%State{assigns: %{headers: headers}} = state) do
case headers["content-security-policy"] do
[policy] ->
directives = String.split(policy, ";")
allowed? =
Enum.any?(directives, fn directive ->
String.contains?(directive, PlausibleWeb.Endpoint.host())
end)
if allowed? do
state
else
put_diagnostics(state, disallowed_via_csp?: true)
end
_ ->
state
end
end
def perform(state), do: state
end

View File

@ -1,61 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.FetchBody do
@moduledoc """
Fetches the body of the site and extracts the HTML document, if available, for
further processing. See `Plausible.InstallationSupport.LegacyVerification.Checks`
for the execution sequence.
"""
use Plausible.InstallationSupport.Check
@impl true
def report_progress_as, do: "We're visiting your site to ensure that everything is working"
@impl true
def perform(%State{url: "https://" <> _ = url} = state) do
fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts =
Keyword.merge(
[
base_url: url,
max_redirects: 4,
max_retries: 3,
retry_log_level: :warning
],
fetch_body_opts
)
{req, resp} = opts |> Req.new() |> Req.Request.run_request()
case resp do
%Req.Response{body: body}
when is_binary(body) ->
state
|> assign(final_domain: req.url.host)
|> extract_document(resp)
_ ->
state
end
end
defp extract_document(state, response) do
with true <- html?(response),
{:ok, document} <- Floki.parse_document(response.body) do
state
|> assign(raw_body: response.body, document: document, headers: response.headers)
|> put_diagnostics(body_fetched?: true)
else
_ ->
state
end
end
defp html?(%Req.Response{headers: headers}) do
headers
|> Map.get("content-type", "")
|> List.wrap()
|> List.first()
|> String.contains?("text/html")
end
end

View File

@ -1,214 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.Installation do
require Logger
alias Plausible.InstallationSupport.BrowserlessConfig
@verifier_code_path "priv/tracker/installation_support/verifier-v1.js"
@external_resource @verifier_code_path
# On CI, the file might not be present for static checks so we default to empty string
@verifier_code (case File.read(Application.app_dir(:plausible, @verifier_code_path)) do
{:ok, content} -> content
{:error, _} -> ""
end)
# Puppeteer wrapper function that executes the vanilla JS verifier code.
# ### NO AUTOMATIC TEST COVERAGE
# Unfortunately, as things stand today, this Puppeteer wrapper logic
# cannot be tested without spinning up a real Browserless instance or
# bringing in a bunch of test deps for Puppeteer. Therefore, take extra
# care when changing this and make sure to run manual tests on local
# browserless (`make browserless`) before releasing an update.
# ### TRICKY: Handling client side JS navigation.
# We've seen numerous cases where client JS navigates or refreshes the
# page after load. Any such JS behaviour destroys the Puppeteer page
# context, meaning that our verifier execution gets interrupted and we
# end up in the `catch` clause.
# To make our best effort verifying these sites, we retry (up to twice)
# running the verifier again if we encounter this specific error.
# Important: On retries, we work with the client-modified page context
# instead of calling `page.goto(context.url)` again (which would most
# likely result in another interruptive navigation).
@puppeteer_wrapper_code """
export default async function({ page, context }) {
const MAX_RETRIES = 2
async function attemptVerification() {
await page.evaluate(() => {
#{@verifier_code}
});
return await page.evaluate(async (expectedDataDomain, debug) => {
return await window.verifyPlausibleInstallation(expectedDataDomain, debug);
}, context.expectedDataDomain, context.debug);
}
try {
await page.setUserAgent(context.userAgent);
await page.goto(context.url);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await attemptVerification()
} catch (error) {
const shouldRetry = typeof error?.message === 'string' && error.message.toLowerCase().includes('execution context')
if (shouldRetry && attempt <= MAX_RETRIES) {
// Brief delay before retry
await new Promise(resolve => setTimeout(resolve, 500))
continue
}
throw error
}
}
} catch (error) {
const msg = error.message ? error.message : JSON.stringify(error)
return {data: {completed: false, error: msg}}
}
}
"""
@moduledoc """
Calls the browserless.io service (local instance can be spawned with `make browserless`)
and runs verifier script via the [function API](https://docs.browserless.io/HTTP-APIs/function).
The verification uses a vanilla JS script that runs in the browser context,
performing a comprehensive Plausible installation verification. Providing
the following information:
- `data.snippetsFoundInHead` - plausible snippets found in <head>
- `data.snippetsFoundInBody` - plausible snippets found in <body>
- `data.plausibleInstalled` - whether or not the `plausible()` window function was found
- `data.callbackStatus` - integer. 202 indicates that the server acknowledged the test event.
The test event ingestion is discarded based on user-agent, see:
`Plausible.InstallationSupport.user_agent/0`
- `data.dataDomainMismatch` - whether or not script[data-domain] mismatched with site.domain
- `data.proxyLikely` - whether the script[src] is not a plausible.io URL
- `data.manualScriptExtension` - whether the site is using script.manual.js
- `data.unknownAttributes` - whether the script tag has any unknown attributes
- `data.wordpressPlugin` - whether or not there's a `<meta>` tag with the WP plugin version
- `data.wordpressLikely` - whether or not the site is built on WordPress
- `data.gtmLikely` - whether or not the site uses GTM
- `data.cookieBannerLikely` - whether or not there's a cookie banner blocking Plausible
"""
use Plausible.InstallationSupport.Check
@impl true
def report_progress_as, do: "We're verifying that your visitors are being counted correctly"
@impl true
def perform(%State{url: url, data_domain: data_domain} = state) do
opts = [
headers: %{content_type: "application/json"},
body:
Jason.encode!(%{
code: @puppeteer_wrapper_code,
context: %{
expectedDataDomain: data_domain,
url: Plausible.InstallationSupport.URL.bust_url(url),
userAgent: Plausible.InstallationSupport.user_agent(),
debug: Application.get_env(:plausible, :environment) == "dev"
}
}),
retry: :transient,
retry_log_level: :warning,
max_retries: 2
]
extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts = Keyword.merge(opts, extra_opts)
case Req.post(BrowserlessConfig.browserless_function_api_endpoint(), opts) do
{:ok, %{status: 200, body: %{"data" => %{"completed" => true} = js_data}}} ->
emit_telemetry_and_log(state.diagnostics, js_data, data_domain)
put_diagnostics(state,
plausible_installed?: js_data["plausibleInstalled"],
callback_status: js_data["callbackStatus"]
)
{:ok, %{status: status, body: %{"data" => %{"error" => error}}}} ->
Logger.warning(
"[VERIFICATION] Browserless JS error (data_domain='#{data_domain}'): #{inspect(error)}"
)
put_diagnostics(state, plausible_installed?: false, service_error: status)
{:ok, %{status: status, body: body}} ->
Logger.warning(
"[VERIFICATION] Unexpected Browserless response (data_domain='#{data_domain}'): status=#{status}, body=#{inspect(body)}"
)
put_diagnostics(state, plausible_installed?: false, service_error: status)
{:error, %{reason: reason}} ->
Logger.warning(
"[VERIFICATION] Browserless request error (data_domain='#{data_domain}'): #{inspect(reason)}"
)
put_diagnostics(state, plausible_installed?: false, service_error: reason)
end
end
def telemetry_event(true = _diff), do: [:plausible, :verification, :js_elixir_diff]
def telemetry_event(false = _diff), do: [:plausible, :verification, :js_elixir_match]
def emit_telemetry_and_log(elixir_data, js_data, data_domain) do
diffs =
for {diff, elixir_diagnostic, js_diagnostic} <- [
{:data_domain_mismatch_diff, :data_domain_mismatch?, "dataDomainMismatch"},
{:proxy_likely_diff, :proxy_likely?, "proxyLikely"},
{:manual_script_extension_diff, :manual_script_extension?, "manualScriptExtension"},
{:unknown_attributes_diff, :snippet_unknown_attributes?, "unknownAttributes"},
{:wordpress_plugin_diff, :wordpress_plugin?, "wordpressPlugin"},
{:wordpress_likely_diff, :wordpress_likely?, "wordpressLikely"},
{:gtm_likely_diff, :gtm_likely?, "gtmLikely"},
{:cookie_banner_likely_diff, :cookie_banner_likely?, "cookieBannerLikely"}
] do
case {Map.get(elixir_data, elixir_diagnostic), js_data[js_diagnostic]} do
{true, false} -> {diff, -1}
{false, true} -> {diff, 1}
{_, _} -> {diff, 0}
end
end
|> Map.new()
|> Map.merge(%{
snippets_head_diff: js_data["snippetsFoundInHead"] - elixir_data.snippets_found_in_head,
snippets_body_diff: js_data["snippetsFoundInBody"] - elixir_data.snippets_found_in_body
})
|> Map.reject(fn {_k, v} -> v == 0 end)
any_diff? = map_size(diffs) > 0
if any_diff? do
info =
%{
domain: data_domain,
plausible_installed_js: js_data["plausibleInstalled"],
callback_status_js: js_data["callbackStatus"]
}
|> Map.merge(diffs)
Logger.warning("[VERIFICATION] js_elixir_diff: #{inspect(info)}")
end
:telemetry.execute(telemetry_event(any_diff?), %{})
end
end

View File

@ -1,82 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.ScanBody do
@moduledoc """
Naive way of detecting GTM and WordPress powered sites.
"""
use Plausible.InstallationSupport.Check
@impl true
def report_progress_as, do: "We're visiting your site to ensure that everything is working"
@impl true
def perform(%State{assigns: %{raw_body: body}} = state) when is_binary(body) do
state
|> scan_wp_plugin()
|> scan_gtm()
|> scan_wp()
|> scan_cookie_banners()
end
def perform(state), do: state
defp scan_wp_plugin(%{assigns: %{document: document}} = state) do
case Floki.find(document, ~s|meta[name="plausible-analytics-version"]|) do
[] ->
state
[_] ->
state
|> assign(skip_wordpress_check: true)
|> put_diagnostics(wordpress_likely?: true, wordpress_plugin?: true)
end
end
defp scan_wp_plugin(state) do
state
end
@gtm_signatures [
"googletagmanager.com/gtm.js"
]
defp scan_gtm(state) do
if Enum.any?(@gtm_signatures, &String.contains?(state.assigns.raw_body, &1)) do
put_diagnostics(state, gtm_likely?: true)
else
state
end
end
@wordpress_signatures [
"wp-content",
"wp-includes",
"wp-json"
]
defp scan_wp(%{assigns: %{skip_wordpress_check: true}} = state) do
state
end
defp scan_wp(state) do
if Enum.any?(@wordpress_signatures, &String.contains?(state.assigns.raw_body, &1)) do
put_diagnostics(state, wordpress_likely?: true)
else
state
end
end
defp scan_cookie_banners(%{assigns: %{raw_body: body}} = state) do
# We'll start with CookieBot. Not using the selectors yet, as seen at
# https://github.com/cavi-au/Consent-O-Matic/blob/master/rules/cookiebot.json
# because those don't seem to be appearing without JS evaluation.
# If this ever becomes an issue, we'll have to move that check to headless.
if String.contains?(body, "cookiebot") do
put_diagnostics(state, cookie_banner_likely?: true)
else
state
end
end
defp scan_cookie_banners(state) do
state
end
end

View File

@ -1,78 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.Snippet do
@moduledoc """
The check looks for Plausible snippets and tries to address the common
integration issues, such as bad placement, data-domain typos, unknown
attributes frequently added by performance optimization plugins, etc.
"""
use Plausible.InstallationSupport.Check
@impl true
def report_progress_as, do: "We're looking for the Plausible snippet on your site"
@impl true
def perform(%State{assigns: %{document: document}} = state) do
in_head = Floki.find(document, "head script[data-domain][src]")
in_body = Floki.find(document, "body script[data-domain][src]")
all = in_head ++ in_body
put_diagnostics(state,
snippets_found_in_head: Enum.count(in_head),
snippets_found_in_body: Enum.count(in_body),
proxy_likely?: proxy_likely?(all),
manual_script_extension?: manual_script_extension?(all),
snippet_unknown_attributes?: unknown_attributes?(all),
data_domain_mismatch?:
data_domain_mismatch?(all, state.data_domain, state.assigns[:final_domain])
)
end
def perform(state), do: state
defp manual_script_extension?(nodes) do
nodes
|> Floki.attribute("src")
|> Enum.any?(&String.contains?(&1, "manual."))
end
defp proxy_likely?(nodes) do
nodes
|> Floki.attribute("src")
|> Enum.any?(&(not String.starts_with?(&1, PlausibleWeb.Endpoint.url())))
end
@known_attributes [
"data-domain",
"src",
"defer",
"data-api",
"data-exclude",
"data-include",
"data-cfasync"
]
defp unknown_attributes?(nodes) do
Enum.any?(nodes, fn {_, attrs, _} ->
Enum.any?(attrs, fn
{"type", "text/javascript"} ->
false
{"event-" <> _, _} ->
false
{key, _} ->
key not in @known_attributes
end)
end)
end
defp data_domain_mismatch?(nodes, data_domain, final_data_domain) do
nodes
|> Floki.attribute("data-domain")
|> Enum.any?(fn script_data_domain ->
multiple = String.split(script_data_domain, ",")
data_domain not in multiple and final_data_domain not in multiple
end)
end
end

View File

@ -1,43 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.SnippetCacheBust do
@moduledoc """
A naive way of trying to figure out whether the latest site contents
is wrapped with some CDN/caching layer.
In case no snippets were found, we'll try to bust the cache by appending
a random query parameter and re-run `FetchBody` and `Snippet` checks.
If the result is different this time, we'll assume cache likely.
"""
use Plausible.InstallationSupport.Check
alias Plausible.InstallationSupport.{LegacyVerification, Checks, URL}
@impl true
def report_progress_as, do: "We're looking for the Plausible snippet on your site"
@impl true
def perform(
%State{
url: url,
diagnostics: %LegacyVerification.Diagnostics{
snippets_found_in_head: 0,
snippets_found_in_body: 0,
body_fetched?: true
}
} = state
) do
state2 =
%{state | url: URL.bust_url(url)}
|> Checks.FetchBody.perform()
|> Checks.ScanBody.perform()
|> Checks.Snippet.perform()
if state2.diagnostics.snippets_found_in_head > 0 or
state2.diagnostics.snippets_found_in_body > 0 do
put_diagnostics(state2, snippet_found_after_busting_cache?: true)
else
state
end
end
def perform(state), do: state
end

View File

@ -1,4 +1,4 @@
defmodule Plausible.InstallationSupport.Checks.InstallationV2 do
defmodule Plausible.InstallationSupport.Checks.VerifyInstallation do
@moduledoc """
Calls the browserless.io service (local instance can be spawned with `make browserless`)
and runs verifier script via the [function API](https://docs.browserless.io/HTTP-APIs/function).
@ -8,7 +8,7 @@ defmodule Plausible.InstallationSupport.Checks.InstallationV2 do
use Plausible.InstallationSupport.Check
alias Plausible.InstallationSupport.BrowserlessConfig
@verifier_code_path "priv/tracker/installation_support/verifier-v2.js"
@verifier_code_path "priv/tracker/installation_support/verifier.js"
@external_resource @verifier_code_path
# On CI, the file might not be present for static checks so we default to empty string
@ -172,7 +172,7 @@ defmodule Plausible.InstallationSupport.Checks.InstallationV2 do
end
defp warning_message(message, state) do
"[VERIFICATION v2] #{message} (data_domain='#{state.data_domain}')"
"[VERIFICATION] #{message} (data_domain='#{state.data_domain}')"
end
defp parse_to_diagnostics(data),

View File

@ -1,8 +1,8 @@
defmodule Plausible.InstallationSupport.Checks.InstallationV2CacheBust do
defmodule Plausible.InstallationSupport.Checks.VerifyInstallationCacheBust do
@moduledoc """
If the output of previous checks can not be interpreted as successful,
as a last resort, we try to bust the cache of the site under test by adding a query parameter to the URL,
and running InstallationV2 again.
and running VerifyInstallation again.
Whatever the result from the rerun, that is what we use to interpret the installation.
@ -38,7 +38,7 @@ defmodule Plausible.InstallationSupport.Checks.InstallationV2CacheBust do
state
|> struct!(diagnostics: reset_diagnostics)
|> struct!(url: InstallationSupport.URL.bust_url(url))
|> InstallationSupport.Checks.InstallationV2.perform()
|> InstallationSupport.Checks.VerifyInstallation.perform()
|> put_diagnostics(diagnostics_are_from_cache_bust: true)
end
end

View File

@ -1,48 +0,0 @@
defmodule Plausible.InstallationSupport.LegacyVerification.Checks do
@moduledoc """
Checks that are performed during v1 site verification.
In async execution, each check notifies the caller by sending a message to it.
"""
alias Plausible.InstallationSupport.LegacyVerification
alias Plausible.InstallationSupport.{State, CheckRunner, Checks}
require Logger
@checks [
Checks.FetchBody,
Checks.CSP,
Checks.ScanBody,
Checks.Snippet,
Checks.SnippetCacheBust,
Checks.Installation
]
def run(url, data_domain, opts \\ []) do
checks = Keyword.get(opts, :checks, @checks)
report_to = Keyword.get(opts, :report_to, self())
async? = Keyword.get(opts, :async?, true)
slowdown = Keyword.get(opts, :slowdown, 500)
init_state =
%State{
url: url,
data_domain: data_domain,
report_to: report_to,
diagnostics: %LegacyVerification.Diagnostics{}
}
CheckRunner.run(init_state, checks,
async?: async?,
report_to: report_to,
slowdown: slowdown
)
end
def interpret_diagnostics(%State{} = state) do
LegacyVerification.Diagnostics.interpret(
state.diagnostics,
state.url
)
end
end

View File

@ -1,391 +0,0 @@
defmodule Plausible.InstallationSupport.LegacyVerification.Diagnostics do
@moduledoc """
Module responsible for translating diagnostics to user-friendly errors and recommendations.
"""
require Logger
@errors Plausible.InstallationSupport.LegacyVerification.Errors.all()
defstruct plausible_installed?: false,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
snippet_found_after_busting_cache?: false,
snippet_unknown_attributes?: false,
disallowed_via_csp?: false,
service_error: nil,
body_fetched?: false,
wordpress_likely?: false,
cookie_banner_likely?: false,
gtm_likely?: false,
callback_status: 0,
proxy_likely?: false,
manual_script_extension?: false,
data_domain_mismatch?: false,
wordpress_plugin?: false
@type t :: %__MODULE__{}
alias Plausible.InstallationSupport.Result
@spec interpret(t(), String.t()) :: Result.t()
def interpret(
%__MODULE__{
plausible_installed?: true,
snippets_found_in_head: 1,
snippets_found_in_body: 0,
callback_status: callback_status,
snippet_found_after_busting_cache?: false,
service_error: nil,
data_domain_mismatch?: false
},
_url
)
when callback_status in [200, 202] do
%Result{ok?: true}
end
def interpret(
%__MODULE__{plausible_installed?: false, gtm_likely?: true, disallowed_via_csp?: true},
_url
) do
error(@errors.csp)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
gtm_likely?: true,
cookie_banner_likely?: true,
wordpress_plugin?: false
},
_url
) do
error(@errors.gtm_cookie_banner)
end
def interpret(
%__MODULE__{plausible_installed?: false, gtm_likely?: true, wordpress_plugin?: false},
_url
) do
error(@errors.gtm)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 1,
disallowed_via_csp?: true,
proxy_likely?: false
},
_url
) do
error(@errors.csp)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
body_fetched?: true,
service_error: nil,
wordpress_likely?: false
},
_url
) do
error(@errors.no_snippet)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
body_fetched?: true,
gtm_likely?: false,
callback_status: callback_status
},
_url
)
when is_integer(callback_status) and callback_status > 202 do
error(@errors.no_snippet)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
body_fetched?: true,
service_error: nil,
wordpress_likely?: true
},
_url
) do
error(@errors.no_snippet_wp)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
body_fetched?: false
},
_url
) do
error(@errors.unreachable)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
service_error: :timeout
},
_url
) do
error(@errors.generic)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
service_error: service_error
},
_url
)
when not is_nil(service_error) do
error(@errors.temporary)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
service_error: nil,
body_fetched?: false
},
_url
) do
error(@errors.unreachable)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
wordpress_likely?: false,
callback_status: -1
},
_url
) do
error(@errors.generic)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
wordpress_likely?: true,
wordpress_plugin?: false,
callback_status: -1
},
_url
) do
error(@errors.old_script_wp_no_plugin)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
wordpress_likely?: true,
wordpress_plugin?: true,
callback_status: -1
},
_url
) do
error(@errors.old_script_wp_plugin)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: callback_status,
proxy_likely?: true
},
_url
)
when callback_status in [0, 500] do
error(@errors.proxy_misconfigured)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 1,
proxy_likely?: true,
wordpress_likely?: true,
wordpress_plugin?: false
},
_url
) do
error(@errors.proxy_wp_no_plugin)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 1,
proxy_likely?: true,
wordpress_likely?: false
},
_url
) do
error(@errors.proxy_general)
end
def interpret(%__MODULE__{data_domain_mismatch?: true}, "https://" <> domain) do
error(@errors.different_data_domain, domain: domain)
end
def interpret(
%__MODULE__{
snippets_found_in_head: count_head,
snippets_found_in_body: count_body,
manual_script_extension?: false
},
_url
)
when count_head + count_body > 1 do
error(@errors.multiple_snippets)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: callback_status,
snippet_found_after_busting_cache?: true,
wordpress_likely?: true,
wordpress_plugin?: true
},
_url
)
when callback_status in [200, 202] do
error(@errors.cache_wp_plugin)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: callback_status,
snippet_found_after_busting_cache?: true,
wordpress_likely?: true,
wordpress_plugin?: false
},
_url
)
when callback_status in [200, 202] do
error(@errors.cache_wp_no_plugin)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: 202,
snippet_found_after_busting_cache?: true,
wordpress_likely?: false
},
_url
) do
error(@errors.cache_general)
end
def interpret(%__MODULE__{snippets_found_in_head: 0, snippets_found_in_body: n}, _url)
when n >= 1 do
error(@errors.snippet_in_body)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippet_unknown_attributes?: true,
wordpress_likely?: true,
wordpress_plugin?: true
},
_url
) do
error(@errors.illegal_attrs_wp_plugin)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippet_unknown_attributes?: true,
wordpress_likely?: true,
wordpress_plugin?: false
},
_url
) do
error(@errors.illegal_attrs_wp_no_plugin)
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippet_unknown_attributes?: true,
wordpress_likely?: false
},
_url
) do
error(@errors.illegal_attrs_general)
end
def interpret(
%__MODULE__{
plausible_installed?: true,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
callback_status: callback_status,
snippet_found_after_busting_cache?: false,
service_error: nil
},
_url
)
when callback_status in [200, 202] do
%Result{ok?: true}
end
def interpret(
%__MODULE__{
plausible_installed?: true,
snippets_found_in_head: count_head,
snippets_found_in_body: count_body,
callback_status: callback_status,
service_error: nil,
manual_script_extension?: true
},
_url
)
when count_head + count_body > 1 and callback_status in [200, 202] do
%Result{ok?: true}
end
def interpret(diagnostics, url) do
Sentry.capture_message("Unhandled case for site verification",
extra: %{
message: inspect(diagnostics),
url: url,
hash: :erlang.phash2(diagnostics)
}
)
error(@errors.unknown)
end
defp error(error) do
%Result{
ok?: false,
errors: [error.message],
recommendations: [%{text: error.recommendation, url: error.url}]
}
end
defp error(error, assigns) do
recommendation = EEx.eval_string(error.recommendation, assigns: assigns)
error(%{error | recommendation: recommendation})
end
end

View File

@ -1,156 +0,0 @@
defmodule Plausible.InstallationSupport.LegacyVerification.Errors do
@moduledoc """
A go-to definition of all legacy verification errors
"""
@errors %{
gtm: %{
message: "We encountered an issue with your Plausible integration",
recommendation:
"As you're using Google Tag Manager, you'll need to use a GTM-specific Plausible snippet",
url: "https://plausible.io/docs/google-tag-manager"
},
gtm_cookie_banner: %{
message: "We couldn't verify your website",
recommendation:
"As you're using Google Tag Manager, you'll need to use a GTM-specific Plausible snippet. Please make sure no cookie consent banner is blocking our script",
url: "https://plausible.io/docs/google-tag-manager"
},
csp: %{
message: "We encountered an issue with your site's CSP",
recommendation:
"Please add plausible.io domain specifically to the allowed list of domains in your Content Security Policy (CSP)",
url:
"https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp"
},
unreachable: %{
message: "We couldn't reach your site",
recommendation:
"If your site is running at a different location, please manually check your integration",
url: "https://plausible.io/docs/troubleshoot-integration"
},
no_snippet: %{
message: "We couldn't find the Plausible snippet",
recommendation: "Please insert the snippet into your site",
url: "https://plausible.io/docs/plausible-script"
},
no_snippet_wp: %{
message: "We couldn't find the Plausible snippet on your site",
recommendation:
"Please install and activate our WordPress plugin to start counting your visitors",
url: "https://plausible.io/wordpress-analytics-plugin"
},
temporary: %{
message: "We encountered a temporary problem",
recommendation: "Please try again in a few minutes or manually check your integration",
url:
"https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"
},
generic: %{
message: "We couldn't automatically verify your website",
recommendation:
"Please manually check your integration by following the instructions provided",
url:
"https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"
},
old_script_wp_no_plugin: %{
message: "We couldn't verify your website",
recommendation:
"You're running an older version of our script so we cannot verify it. Please use our WordPress plugin instead",
url: "https://plausible.io/wordpress-analytics-plugin"
},
old_script_wp_plugin: %{
message: "We couldn't verify your website",
recommendation:
"You're running an older version of our script so we cannot verify it. Please re-enable the proxy in our plugin",
url: "https://plausible.io/wordpress-analytics-plugin"
},
proxy_misconfigured: %{
message: "We encountered an error with your Plausible proxy",
recommendation: "Please check whether you've configured the /event route correctly",
url: "https://plausible.io/docs/proxy/introduction"
},
proxy_wp_no_plugin: %{
message: "We encountered an error with your Plausible proxy",
recommendation:
"Please re-enable the proxy in our WordPress plugin to start counting your visitors",
url: "https://plausible.io/wordpress-analytics-plugin"
},
proxy_general: %{
message: "We encountered an error with your Plausible proxy",
recommendation: "Please check your proxy configuration to make sure it's set up correctly",
url: "https://plausible.io/docs/proxy/introduction"
},
multiple_snippets: %{
message: "We've found multiple Plausible snippets",
recommendation: "Please ensure that only one snippet is used",
url:
"https://plausible.io/docs/troubleshoot-integration#did-you-insert-multiple-plausible-snippets-into-your-site"
},
cache_wp_plugin: %{
message: "We encountered an issue with your site cache",
recommendation:
"Please clear your WordPress cache to ensure that the latest version is displayed to your visitors",
url: "https://plausible.io/wordpress-analytics-plugin"
},
cache_wp_no_plugin: %{
message: "We encountered an issue with your site cache",
recommendation:
"Please install and activate our WordPress plugin to start counting your visitors",
url: "https://plausible.io/wordpress-analytics-plugin"
},
cache_general: %{
message: "We encountered an issue with your site cache",
recommendation:
"Please clear your cache (or wait for your provider to clear it) to ensure that the latest version is displayed to your visitors",
url:
"https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site"
},
snippet_in_body: %{
message: "Plausible snippet is placed in the body",
recommendation: "Please relocate the snippet to the header of your site",
url: "https://plausible.io/docs/troubleshoot-integration"
},
different_data_domain: %{
message: "Your data-domain is different",
recommendation: "Please ensure that the data-domain matches <%= @domain %> exactly",
url:
"https://plausible.io/docs/troubleshoot-integration#have-you-added-the-correct-data-domain-attribute-in-the-plausible-snippet"
},
illegal_attrs_wp_plugin: %{
message: "A performance optimization plugin seems to have altered our snippet",
recommendation:
"Please whitelist our script in your performance optimization plugin to stop it from changing our snippet",
url:
"https://plausible.io/docs/troubleshoot-integration#has-some-other-plugin-altered-our-snippet"
},
illegal_attrs_wp_no_plugin: %{
message: "A performance optimization plugin seems to have altered our snippet",
recommendation:
"Please install and activate our WordPress plugin to avoid the most common plugin conflicts",
url: "https://plausible.io/wordpress-analytics-plugin"
},
illegal_attrs_general: %{
message: "Something seems to have altered our snippet",
recommendation:
"Please manually check your integration to make sure that nothing prevents our script from working",
url:
"https://plausible.io/docs/troubleshoot-integration#has-some-other-plugin-altered-our-snippet"
},
unknown: %{
message: "Your Plausible integration is not working",
recommendation:
"Please manually check your integration to make sure that the Plausible snippet has been inserted correctly",
url:
"https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"
}
}
def all(), do: @errors
for {_, %{message: message, recommendation: recommendation} = e} <- @errors do
if String.ends_with?(message, ".") or String.ends_with?(recommendation, ".") do
raise "Error message/recommendation should not end with a period: #{inspect(e)}"
end
end
end

View File

@ -14,8 +14,7 @@ defmodule Plausible.InstallationSupport.State do
skip_further_checks?: false
@type diagnostics_type ::
Plausible.InstallationSupport.LegacyVerification.Diagnostics.t()
| Plausible.InstallationSupport.Verification.Diagnostics.t()
Plausible.InstallationSupport.Verification.Diagnostics.t()
| Plausible.InstallationSupport.Detection.Diagnostics.t()
@type t :: %__MODULE__{

View File

@ -11,11 +11,11 @@ defmodule Plausible.InstallationSupport.Verification.Checks do
@checks [
Checks.Url,
Checks.InstallationV2,
Checks.InstallationV2CacheBust
Checks.VerifyInstallation,
Checks.VerifyInstallationCacheBust
]
@spec run(String.t(), String.t(), String.t(), Keyword.t()) :: :ok
@spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t()
def run(url, data_domain, installation_type, opts \\ []) do
report_to = Keyword.get(opts, :report_to, self())
async? = Keyword.get(opts, :async?, true)
@ -65,8 +65,8 @@ defmodule Plausible.InstallationSupport.Verification.Checks do
{_, %{unhandled: true, browserless_issue: browserless_issue}} ->
sentry_msg =
if browserless_issue,
do: "Browserless failure in verification (v2)",
else: "Unhandled case for site verification (v2)"
do: "Browserless failure in verification",
else: "Unhandled case for site verification"
Sentry.capture_message(sentry_msg,
extra: %{
@ -77,7 +77,7 @@ defmodule Plausible.InstallationSupport.Verification.Checks do
)
Logger.warning(
"[VERIFICATION v2] Unhandled case (data_domain='#{data_domain}'): #{inspect(diagnostics)}"
"[VERIFICATION] Unhandled case (data_domain='#{data_domain}'): #{inspect(diagnostics)}"
)
:telemetry.execute(telemetry_event_unhandled(), %{})

View File

@ -119,7 +119,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
case Repo.transact(fn ->
with {:ok, %{site: site}} <- Sites.create(user, params, team),
{:ok, tracker_script_configuration} <-
get_or_create_config(site, params["tracker_script_configuration"] || %{}, user) do
get_or_create_config(site, params["tracker_script_configuration"] || %{}) do
{:ok,
struct(site,
tracker_script_configuration: tracker_script_configuration
@ -135,7 +135,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
end) do
{:ok, site} ->
json(conn, get_site_response(site, user))
json(conn, get_site_response(site))
{:error, {_, {:over_limit, limit}, _}} ->
conn
@ -176,16 +176,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
team = conn.assigns.current_team
with {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]),
{:ok, tracker_script_configuration} <- get_or_create_config(site, %{}, user) do
{:ok, tracker_script_configuration} <- get_or_create_config(site, %{}) do
site = struct(site, tracker_script_configuration: tracker_script_configuration)
json(
conn,
get_site_response(
site,
user
)
)
json(conn, get_site_response(site))
else
{:error, :site_not_found} ->
H.not_found(conn, "Site could not be found")
@ -212,8 +206,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
with {:ok, params} <- validate_update_payload(params),
{:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]),
{:ok, site} <- do_update_site(site, params, user) do
json(conn, get_site_response(site, user))
{:ok, site} <- do_update_site(site, params) do
json(conn, get_site_response(site))
else
{:error, :site_not_found} ->
H.not_found(conn, "Site could not be found")
@ -246,7 +240,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
end
defp do_update_site(site, params, user) do
defp do_update_site(site, params) do
Repo.transact(fn ->
with {:ok, site} <-
if(params["domain"],
@ -255,8 +249,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
),
{:ok, tracker_script_configuration} <-
if(params["tracker_script_configuration"],
do: update_config(site, params["tracker_script_configuration"], user),
else: get_or_create_config(site, %{}, user)
do: update_config(site, params["tracker_script_configuration"]),
else: get_or_create_config(site, %{})
) do
{:ok, struct(site, tracker_script_configuration: tracker_script_configuration)}
end
@ -581,31 +575,23 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
end
defp get_or_create_config(site, params, user) do
if PlausibleWeb.Tracker.scriptv2?(site, user) do
case PlausibleWeb.Tracker.get_or_create_tracker_script_configuration(site, params) do
{:ok, tracker_script_configuration} ->
{:ok, tracker_script_configuration}
defp get_or_create_config(site, params) do
case PlausibleWeb.Tracker.get_or_create_tracker_script_configuration(site, params) do
{:ok, tracker_script_configuration} ->
{:ok, tracker_script_configuration}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, {:tracker_script_configuration_invalid, changeset}}
end
else
{:ok, %{}}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, {:tracker_script_configuration_invalid, changeset}}
end
end
defp update_config(site, params, user) do
if PlausibleWeb.Tracker.scriptv2?(site, user) do
case PlausibleWeb.Tracker.update_script_configuration(site, params, :installation) do
{:ok, tracker_script_configuration} ->
{:ok, tracker_script_configuration}
defp update_config(site, params) do
case PlausibleWeb.Tracker.update_script_configuration(site, params, :installation) do
{:ok, tracker_script_configuration} ->
{:ok, tracker_script_configuration}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, {:tracker_script_configuration_invalid, changeset}}
end
else
{:ok, %{}}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, {:tracker_script_configuration_invalid, changeset}}
end
end
@ -613,15 +599,9 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
site |> Map.take([:domain, :timezone])
end
defp get_site_response(site, user) do
serializable_properties =
if(PlausibleWeb.Tracker.scriptv2?(site, user),
do: [:domain, :timezone, :tracker_script_configuration],
else: [:domain, :timezone]
)
defp get_site_response(site) do
site
|> Map.take(serializable_properties)
|> Map.take([:domain, :timezone, :tracker_script_configuration])
# remap to `custom_properties`
|> Map.put(:custom_properties, site.allowed_event_props || [])
end

View File

@ -8,7 +8,7 @@ defmodule PlausibleWeb.Live.Verification do
import PlausibleWeb.Components.Generic
alias Plausible.InstallationSupport.{State, LegacyVerification, Verification}
alias Plausible.InstallationSupport.{State, Verification}
@component PlausibleWeb.Live.Components.Verification
@slowdown_for_frequent_checking :timer.seconds(5)
@ -38,8 +38,7 @@ defmodule PlausibleWeb.Live.Verification do
super_admin? = Plausible.Auth.is_super_admin?(current_user)
has_pageviews? = has_pageviews?(site)
custom_url_input? =
PlausibleWeb.Tracker.scriptv2?(site, current_user) and params["custom_url"] == "true"
custom_url_input? = params["custom_url"] == "true"
socket =
assign(socket,
@ -49,7 +48,7 @@ defmodule PlausibleWeb.Live.Verification do
domain: domain,
has_pageviews?: has_pageviews?,
component: @component,
installation_type: get_installation_type(params, site, current_user),
installation_type: get_installation_type(params, site),
report_to: self(),
delay: private[:delay] || 500,
slowdown: private[:slowdown] || 500,
@ -122,22 +121,13 @@ defmodule PlausibleWeb.Live.Verification do
end
{:ok, pid} =
if PlausibleWeb.Tracker.scriptv2?(socket.assigns.site, socket.assigns.current_user) do
Verification.Checks.run(
socket.assigns.url_to_verify,
domain,
socket.assigns.installation_type,
report_to: report_to,
slowdown: socket.assigns.slowdown
)
else
LegacyVerification.Checks.run(
"https://#{domain}",
domain,
report_to: report_to,
slowdown: socket.assigns.slowdown
)
end
Verification.Checks.run(
socket.assigns.url_to_verify,
domain,
socket.assigns.installation_type,
report_to: report_to,
slowdown: socket.assigns.slowdown
)
{:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)}
end
@ -159,12 +149,7 @@ defmodule PlausibleWeb.Live.Verification do
end
def handle_info({:all_checks_done, %State{} = state}, socket) do
interpretation =
if PlausibleWeb.Tracker.scriptv2?(socket.assigns.site, socket.assigns.current_user) do
Verification.Checks.interpret_diagnostics(state)
else
LegacyVerification.Checks.interpret_diagnostics(state)
end
interpretation = Verification.Checks.interpret_diagnostics(state)
if not socket.assigns.has_pageviews? do
schedule_pageviews_check(socket)
@ -195,20 +180,16 @@ defmodule PlausibleWeb.Live.Verification do
@supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types()
|> Enum.map(&String.to_atom/1)
defp get_installation_type(params, site, current_user) do
if PlausibleWeb.Tracker.scriptv2?(site, current_user) do
cond do
params["installation_type"] in PlausibleWeb.Tracker.supported_installation_types() ->
params["installation_type"]
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)
(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
else
params["installation_type"]
true ->
PlausibleWeb.Tracker.fallback_installation_type()
end
end

View File

@ -182,22 +182,6 @@ defmodule Plausible.PromEx.Plugins.PlausibleMetrics do
tags: [:status],
tag_values: &%{status: &1.status}
),
on_ee(
do:
counter(
metric_prefix ++ [:verification, :js_elixir_diff],
event_name:
Plausible.InstallationSupport.Checks.Installation.telemetry_event(_diff = true)
)
),
on_ee(
do:
counter(
metric_prefix ++ [:verification, :js_elixir_match],
event_name:
Plausible.InstallationSupport.Checks.Installation.telemetry_event(_diff = false)
)
),
on_ee(
do:
counter(

View File

@ -747,41 +747,6 @@ defmodule PlausibleWeb.SiteController do
)
end
def change_domain(conn, _params) do
if PlausibleWeb.Tracker.scriptv2?(conn.assigns.site) do
redirect(conn,
to: Routes.site_path(conn, :change_domain_v2, conn.assigns.site.domain)
)
else
changeset = Plausible.Site.update_changeset(conn.assigns.site)
render(conn, "change_domain.html",
skip_plausible_tracking: true,
changeset: changeset
)
end
end
def change_domain_submit(conn, %{"site" => %{"domain" => new_domain}}) do
case Plausible.Site.Domain.change(conn.assigns.site, new_domain) do
{:ok, updated_site} ->
conn
|> put_flash(:success, "Website domain changed successfully")
|> redirect(
to:
Routes.site_path(conn, :installation, updated_site.domain,
flow: PlausibleWeb.Flows.domain_change()
)
)
{:error, changeset} ->
render(conn, "change_domain.html",
skip_plausible_tracking: true,
changeset: changeset
)
end
end
defp tolerate_unique_contraint_violation(result, name) do
case result do
{:ok, _} ->

View File

@ -1,12 +1,12 @@
defmodule PlausibleWeb.Live.ChangeDomainV2 do
defmodule PlausibleWeb.Live.ChangeDomain do
@moduledoc """
LiveView for the change domain v2 flow.
LiveView for the change domain flow.
"""
use Plausible
use PlausibleWeb, :live_view
alias PlausibleWeb.Router.Helpers, as: Routes
alias PlausibleWeb.Live.ChangeDomainV2.Form
alias PlausibleWeb.Live.ChangeDomain.Form
alias Phoenix.LiveView.AsyncResult
on_ee do
@ -59,7 +59,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2 do
end
end
def render(%{live_action: :change_domain_v2} = assigns) do
def render(%{live_action: :change_domain} = assigns) do
render_form_step(assigns)
end

View File

@ -1,4 +1,4 @@
defmodule PlausibleWeb.Live.ChangeDomainV2.Form do
defmodule PlausibleWeb.Live.ChangeDomain.Form do
@moduledoc """
Live component for the change domain form
"""

View File

@ -2,33 +2,21 @@ defmodule PlausibleWeb.Live.Installation do
@moduledoc """
User assistance module around Plausible installation instructions/onboarding
"""
use Plausible
use PlausibleWeb, :live_view
require Logger
alias PlausibleWeb.Flows
alias Phoenix.LiveView.AsyncResult
alias PlausibleWeb.Live.Installation.Icons
alias PlausibleWeb.Live.Installation.Instructions
on_ee do
alias Plausible.InstallationSupport.{State, Checks, LegacyVerification}
alias Plausible.InstallationSupport.{Detection, Result}
end
@script_extension_params %{
"outbound_links" => "outbound-links",
"tagged_events" => "tagged-events",
"file_downloads" => "file-downloads",
"hash_based_routing" => "hash",
"pageview_props" => "pageview-props",
"revenue_tracking" => "revenue"
}
@script_config_params ["track_404_pages" | Map.keys(@script_extension_params)]
@installation_types [
"gtm",
"manual",
"wordpress"
]
@valid_qs_params @script_config_params ++ ["installation_type", "flow"]
def script_extension_params, do: @script_extension_params
def mount(
%{"domain" => domain} = params,
_session,
@ -45,506 +33,320 @@ defmodule PlausibleWeb.Live.Installation do
]
)
if PlausibleWeb.Tracker.scriptv2?(site) do
{:ok,
redirect(socket,
to:
Routes.site_path(
socket,
:installation_v2,
site.domain,
[flow: params["flow"], type: params["installation_type"]]
|> Keyword.filter(fn {_k, v} -> not is_nil(v) and v != "" end)
)
)}
else
flow = params["flow"]
tracker_script_configuration =
PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site)
installation_type = get_installation_type(flow, tracker_script_configuration, params)
config =
Map.new(@script_config_params, fn key ->
string_key = String.to_existing_atom(key)
{key, Map.get(tracker_script_configuration, string_key)}
end)
flow = params["flow"] || Flows.provisioning()
socket =
on_ee do
if connected?(socket) and is_nil(installation_type) do
LegacyVerification.Checks.run("https://#{domain}", domain,
checks: [
Checks.FetchBody,
Checks.ScanBody
if connected?(socket) do
assign_async(
socket,
[
:recommended_installation_type,
:installation_type,
:tracker_script_configuration_form,
:v1_detected
],
report_to: self(),
async?: true,
slowdown: 0
fn -> initialize_installation_data(flow, site, params) end
)
else
assign_loading_states(socket)
end
else
# On Community Edition, there's no v1 detection, nor pre-installation
# site scan - we just default the pre-selected tab to "manual".
# Although it's functionally unnecessary, we stick to using `%AsyncResult{}`
# for these assigns to minimize branching out the CE code and maintain only
# a single `render` function.
{:ok, installation_data} = initialize_installation_data(flow, site, params)
assign(socket,
recommended_installation_type: %AsyncResult{
result: installation_data.recommended_installation_type,
ok?: true
},
installation_type: %AsyncResult{
result: installation_data.installation_type,
ok?: true
},
tracker_script_configuration_form: %AsyncResult{
result: installation_data.tracker_script_configuration_form,
ok?: true
},
v1_detected: %AsyncResult{
result: installation_data.v1_detected,
ok?: true
}
)
end
{:ok,
assign(socket,
uri_params: Map.take(params, @valid_qs_params),
connected?: connected?(socket),
site: site,
site_created?: params["site_created"] == "true",
flow: flow,
installation_type: installation_type,
initial_installation_type: installation_type,
domain: domain,
config: config
)}
end
{:ok,
assign(socket,
site: site,
flow: flow
)}
end
on_ee do
def handle_info({:all_checks_done, %State{} = state}, socket) do
installation_type =
case state.diagnostics do
%{wordpress_likely?: true} -> "wordpress"
%{gtm_likely?: true} -> "gtm"
_ -> "manual"
end
def handle_params(params, _url, socket) do
socket =
if connected?(socket) && socket.assigns.recommended_installation_type.result &&
params["type"] in PlausibleWeb.Tracker.supported_installation_types() do
assign(socket,
installation_type: %AsyncResult{result: params["type"]}
)
else
socket
end
{:noreply,
assign(socket,
initial_installation_type: installation_type,
installation_type: installation_type
)}
end
def handle_info(_msg, socket) do
{:noreply, socket}
end
{:noreply, socket}
end
def render(assigns) do
~H"""
<div>
<.flash_messages flash={@flash} />
<PlausibleWeb.Components.FirstDashboardLaunchBanner.set :if={@site_created?} site={@site} />
<PlausibleWeb.Components.FlowProgress.render flow={@flow} current_step="Install Plausible" />
<.focus_box>
<:title :if={is_nil(@installation_type)}>
<div class="flex w-full mx-auto justify-center">
<.spinner class="spinner block text-center h-8 w-8" />
</div>
</:title>
<:title :if={@installation_type == "wordpress"}>
Install WordPress plugin
</:title>
<:title :if={@installation_type == "gtm"}>
Install Google Tag Manager
</:title>
<:title :if={@installation_type == "manual"}>
Manual installation
</:title>
<.async_result :let={recommended_installation_type} assign={@recommended_installation_type}>
<:loading>
<div class="text-center text-gray-500">
{if(@flow == Flows.review(),
do: "Scanning your site to detect how Plausible is integrated...",
else: "Determining the simplest integration path for your website..."
)}
</div>
<div class="flex items-center justify-center py-8">
<.spinner class="w-6 h-6" />
</div>
</:loading>
<:subtitle :if={is_nil(@installation_type)}>
<div class="text-center mt-8">
Determining installation type...
<.styled_link
:if={@connected?}
href="#"
phx-click="switch-installation-type"
phx-value-method="manual"
<div class="grid grid-cols-2 sm:flex sm:flex-row gap-2 bg-gray-100 dark:bg-gray-900 rounded-md p-1">
<.tab
patch={"?type=manual&flow=#{@flow}"}
selected={@installation_type.result == "manual"}
>
Skip
</.styled_link>
</div>
</:subtitle>
<:subtitle :if={@flow == PlausibleWeb.Flows.domain_change()}>
<p class="mb-4">
Your domain has been changed.
<strong>
You must update the Plausible Installation on your site within 72 hours to guarantee continuous tracking.
</strong>
<br />
<br /> If you're using the API, please also make sure to update your API credentials.
</p>
</:subtitle>
<:subtitle :if={@flow == PlausibleWeb.Flows.review() and not is_nil(@installation_type)}>
<p class="mb-4">
Review your existing installation. You can skip this step and proceed to verifying your installation.
</p>
</:subtitle>
<:subtitle :if={@installation_type == "wordpress"}>
We've detected your website is using WordPress. Here's how to integrate Plausible:
<.focus_list>
<:item>
<.styled_link href="https://plausible.io/wordpress-analytics-plugin" new_tab={true}>
Install our WordPress plugin
</.styled_link>
</:item>
<:item>
After activating our plugin, click the button below to verify your installation
</:item>
</.focus_list>
</:subtitle>
<:subtitle :if={@installation_type == "gtm"}>
We've detected your website is using Google Tag Manager. Here's how to integrate Plausible:
<.focus_list>
<:item>
<.styled_link href="https://plausible.io/docs/google-tag-manager" new_tab={true}>
Read our Tag Manager guide
</.styled_link>
</:item>
<:item>
Paste this snippet into GTM's Custom HTML section. Once done, click the button below to verify your installation.
</:item>
</.focus_list>
</:subtitle>
<:subtitle :if={@installation_type == "manual"}>
Paste this snippet into the <code>&lt;head&gt;</code>
section of your site. See our
<.styled_link href="https://plausible.io/docs/integration-guides" new_tab={true}>
installation guides.
</.styled_link>
Once done, click the button below to verify your installation.
</:subtitle>
<div :if={@installation_type in ["manual", "gtm"]}>
<.snippet_form installation_type={@installation_type} config={@config} domain={@domain} />
</div>
<.button_link
:if={not is_nil(@installation_type)}
href={"/#{URI.encode_www_form(@domain)}/verification?#{URI.encode_query(@uri_params)}"}
type="submit"
class="w-full mt-8"
>
<%= if @flow == PlausibleWeb.Flows.domain_change() do %>
I understand, I'll update my website
<% else %>
<%= if @flow == PlausibleWeb.Flows.review() do %>
Verify your installation
<% else %>
Start collecting data
<Icons.script_icon /> Script
</.tab>
<.tab
patch={"?type=wordpress&flow=#{@flow}"}
selected={@installation_type.result == "wordpress"}
>
<Icons.wordpress_icon /> WordPress
</.tab>
<%= on_ee do %>
<.tab patch={"?type=gtm&flow=#{@flow}"} selected={@installation_type.result == "gtm"}>
<Icons.tag_manager_icon /> Tag Manager
</.tab>
<% end %>
<.tab patch={"?type=npm&flow=#{@flow}"} selected={@installation_type.result == "npm"}>
<Icons.npm_icon /> NPM
</.tab>
</div>
<%= on_ee do %>
<.outdated_script_notice
:if={@v1_detected.result == true}
recommended_installation_type={@recommended_installation_type}
installation_type={@installation_type}
/>
<% end %>
</.button_link>
<:footer :if={@initial_installation_type == "wordpress" and @installation_type == "manual"}>
<.styled_link href={} phx-click="switch-installation-type" phx-value-method="wordpress">
Click here
</.styled_link>
if you prefer WordPress installation method.
</:footer>
<.form for={@tracker_script_configuration_form.result} phx-submit="submit" class="mt-4">
<.input
type="hidden"
field={@tracker_script_configuration_form.result[:installation_type]}
value={@installation_type.result}
/>
<Instructions.manual_instructions
:if={@installation_type.result == "manual"}
tracker_script_configuration_form={@tracker_script_configuration_form.result}
/>
<:footer :if={
(@initial_installation_type == "gtm" and @installation_type == "manual") or
(@initial_installation_type == "manual" and @installation_type == "manual")
}>
<.styled_link href={} phx-click="switch-installation-type" phx-value-method="gtm">
Click here
</.styled_link>
if you prefer Google Tag Manager installation method.
</:footer>
<Instructions.wordpress_instructions
:if={@installation_type.result == "wordpress"}
flow={@flow}
recommended_installation_type={recommended_installation_type}
/>
<%= on_ee do %>
<Instructions.gtm_instructions
:if={@installation_type.result == "gtm"}
recommended_installation_type={recommended_installation_type}
tracker_script_configuration_form={@tracker_script_configuration_form.result}
/>
<% end %>
<Instructions.npm_instructions :if={@installation_type.result == "npm"} />
<:footer :if={not is_nil(@installation_type) and @installation_type != "manual"}>
<.styled_link href={} phx-click="switch-installation-type" phx-value-method="manual">
Click here
</.styled_link>
if you prefer manual installation method.
<.button type="submit" class="w-full mt-8">
{verify_cta(@installation_type.result)}
</.button>
</.form>
</.async_result>
<:footer :if={ce?() and @installation_type.result == "manual"}>
<.focus_list>
<:item>
Still using the legacy snippet with the data-domain attribute? See
<.styled_link href="https://plausible.io/docs/script-update-guide">
migration guide
</.styled_link>
</:item>
</.focus_list>
</:footer>
</.focus_box>
</div>
"""
end
defp render_snippet("manual", domain, %{"track_404_pages" => true} = script_config) do
script_config = Map.put(script_config, "track_404_pages", false)
defp verify_cta("manual"), do: "Verify Script installation"
defp verify_cta("wordpress"), do: "Verify WordPress installation"
defp verify_cta("gtm"), do: "Verify Tag Manager installation"
defp verify_cta("npm"), do: "Verify NPM installation"
"""
#{render_snippet("manual", domain, script_config)}
#{render_snippet_404()}
"""
end
defp render_snippet("manual", domain, script_config) do
~s|<script defer data-domain="#{domain}" src="#{tracker_url(script_config)}"></script>|
end
defp render_snippet("gtm", domain, %{"track_404_pages" => true} = script_config) do
script_config = Map.put(script_config, "track_404_pages", false)
"""
#{render_snippet("gtm", domain, script_config)}
#{render_snippet_404("gtm")}
"""
end
defp render_snippet("gtm", domain, script_config) do
"""
<script>
var script = document.createElement('script');
script.defer = true;
script.dataset.domain = "#{domain}";
script.dataset.api = "https://plausible.io/api/event";
script.src = "#{tracker_url(script_config)}";
document.getElementsByTagName('head')[0].appendChild(script);
</script>
"""
end
def render_snippet_404() do
"<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>"
end
def render_snippet_404("gtm") do
render_snippet_404()
end
defp script_extension_control(assigns) do
~H"""
<div class="mt-2 p-1 text-sm">
<div class="flex items-center">
<input
type="checkbox"
id={"check-#{@variant}"}
name={@variant}
checked={Map.get(@config, @variant, false)}
class="block h-5 w-5 rounded-sm dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600 mr-2"
/>
<label for={"check-#{@variant}"}>
{@label}
</label>
<div class="ml-2 collapse md:visible">
<.tooltip sticky?={false}>
<:tooltip_content>
{@tooltip}
<br /><br />Click to learn more.
</:tooltip_content>
<a href={@learn_more} target="_blank" rel="noopener noreferrer">
<Heroicons.information_circle class="text-indigo-700 dark:text-gray-500 w-5 h-5 hover:stroke-2" />
</a>
</.tooltip>
</div>
<div class="ml-2 visible md:invisible">
<a href={@learn_more} target="_blank" rel="noopener noreferrer">
<Heroicons.information_circle class="text-indigo-700 dark:text-gray-500 w-5 h-5 hover:stroke-2" />
</a>
</div>
</div>
</div>
"""
end
defp snippet_form(assigns) do
~H"""
<form id="snippet-form" phx-change="update-script-config">
<div class="relative">
<textarea
id="snippet"
class="w-full border-1 border-gray-300 rounded-md p-4 text-sm text-gray-700 dark:border-gray-500 dark:bg-gray-900 dark:text-gray-300"
rows="5"
readonly
><%= render_snippet(@installation_type, @domain, @config) %></textarea>
<a
onclick="var input = document.getElementById('snippet'); input.focus(); input.select(); document.execCommand('copy'); event.stopPropagation();"
href="javascript:void(0)"
class="absolute flex items-center text-xs font-medium text-indigo-600 no-underline hover:underline bottom-2 right-4 p-2 bg-white dark:bg-gray-900"
>
<Heroicons.document_duplicate class="pr-1 text-indigo-600 dark:text-indigo-500 w-5 h-5" />
<span>
COPY
</span>
</a>
</div>
<.h2 class="mt-8 text-sm font-medium">Enable optional measurements:</.h2>
<.script_extension_control
config={@config}
variant="outbound_links"
label="Outbound links"
tooltip="Automatically track clicks on external links. These count towards your billable pageviews."
learn_more="https://plausible.io/docs/outbound-link-click-tracking"
/>
<.script_extension_control
config={@config}
variant="file_downloads"
label="File downloads"
tooltip="Automatically track file downloads. These count towards your billable pageviews."
learn_more="https://plausible.io/docs/file-downloads-tracking"
/>
<.script_extension_control
config={@config}
variant="track_404_pages"
label="404 error pages"
tooltip="Find 404 error pages on your site. These count towards your billable pageviews. Additional action required."
learn_more="https://plausible.io/docs/error-pages-tracking-404"
/>
<.script_extension_control
config={@config}
variant="hash_based_routing"
label="Hashed page paths"
tooltip="Automatically track page paths that use a # in the URL."
learn_more="https://plausible.io/docs/hash-based-routing"
/>
<.script_extension_control
config={@config}
variant="tagged_events"
label="Custom events"
tooltip="Tag site elements like buttons, links and forms to track user activity. These count towards your billable pageviews. Additional action required."
learn_more="https://plausible.io/docs/custom-event-goals"
/>
<.script_extension_control
config={@config}
variant="pageview_props"
label="Custom properties"
tooltip="Attach custom properties (also known as custom dimensions) to pageviews or custom events to create custom metrics. Additional action required."
learn_more="https://plausible.io/docs/custom-props/introduction"
/>
<.script_extension_control
config={@config}
variant="revenue_tracking"
label="Ecommerce revenue"
tooltip="Assign monetary values to purchases and track revenue attribution. Additional action required."
learn_more="https://plausible.io/docs/ecommerce-revenue-tracking"
/>
</form>
"""
end
def handle_event("switch-installation-type", %{"method" => method}, socket)
when method in @installation_types do
socket = update_uri_params(socket, %{"installation_type" => method})
{:noreply, socket}
end
def handle_event("update-script-config", params, socket) do
new_config =
@script_config_params
|> Map.new(fn key -> {key, Map.get(params, key) == "on"} end)
flash = snippet_change_flash(socket.assigns.config, new_config)
socket =
if flash do
put_live_flash(socket, :success, flash)
on_ee do
defp detect_recommended_installation_type(flow, site) do
with {:ok, detection_result} <-
Detection.Checks.run_with_rate_limit(nil, site.domain,
detect_v1?: flow == Flows.review(),
report_to: nil,
slowdown: 0,
async?: false
),
%Result{ok?: true, data: data} <-
Detection.Checks.interpret_diagnostics(detection_result) do
{data.suggested_technology, data.v1_detected}
else
socket
_ -> {PlausibleWeb.Tracker.fallback_installation_type(), false}
end
end
else
defp detect_recommended_installation_type(_flow, _site) do
{PlausibleWeb.Tracker.fallback_installation_type(), false}
end
end
on_ee do
defp outdated_script_notice(assigns) do
~H"""
<div :if={
@recommended_installation_type.result == "manual" and
@installation_type.result == "manual"
}>
<.notice class="mt-4" theme={:yellow}>
Your website is running an outdated version of the tracking script. Please
<.styled_link new_tab href="https://plausible.io/docs/script-update-guide">
update
</.styled_link>
your tracking script before continuing
</.notice>
</div>
<div :if={
@recommended_installation_type.result == "gtm" and
@installation_type.result == "gtm"
}>
<.notice class="mt-4" theme={:yellow}>
Your website might be using an outdated version of our Google Tag Manager template.
If so,
<.styled_link new_tab href="https://plausible.io/docs/script-update-guide#gtm">
update
</.styled_link>
your Google Tag Manager template before continuing
</.notice>
</div>
"""
end
defp assign_loading_states(socket) do
assign(socket,
recommended_installation_type: AsyncResult.loading(),
v1_detected: AsyncResult.loading(),
installation_type: AsyncResult.loading(),
tracker_script_configuration_form: AsyncResult.loading()
)
end
end
attr :selected, :boolean, default: false
attr :patch, :string, required: true
slot :inner_block, required: true
defp tab(assigns) do
assigns =
if assigns[:selected] do
assign(assigns,
class:
"bg-white dark:bg-gray-800 rounded-md px-3.5 py-2.5 text-sm font-medium flex items-center flex-1 justify-center whitespace-nowrap"
)
else
assign(assigns,
class:
"bg-gray-100 dark:bg-gray-700 rounded-md px-3.5 py-2.5 text-sm font-medium flex items-center cursor-pointer flex-1 justify-center whitespace-nowrap"
)
end
socket = update_uri_params(socket, new_config)
{:noreply, socket}
~H"""
<.link patch={@patch} class={@class}>
{render_slot(@inner_block)}
</.link>
"""
end
def handle_params(params, _uri, socket) do
socket =
socket
|> update_installation_type(params)
|> update_script_config(params)
|> persist_tracker_script_configuration()
def handle_event("submit", %{"tracker_script_configuration" => params}, socket) do
config =
PlausibleWeb.Tracker.update_script_configuration!(
socket.assigns.site,
params,
:installation
)
{:noreply, socket}
{:noreply,
push_navigate(socket,
to:
Routes.site_path(socket, :verification, socket.assigns.site.domain,
flow: socket.assigns.flow,
installation_type: config.installation_type
)
)}
end
defp update_installation_type(socket, %{"installation_type" => installation_type})
when installation_type in @installation_types do
assign(socket,
installation_type: installation_type,
uri_params: Map.put(socket.assigns.uri_params, "installation_type", installation_type)
)
end
defp initialize_installation_data(flow, site, params) do
{recommended_installation_type, v1_detected} =
detect_recommended_installation_type(flow, site)
defp update_installation_type(socket, _params), do: socket
defp update_script_config(socket, params) do
configuration_update =
@script_config_params
|> Enum.filter(&Map.has_key?(params, &1))
|> Map.new(fn key -> {key, Map.get(params, key) == "true"} end)
assign(socket,
config: Map.merge(socket.assigns.config, configuration_update)
)
end
defp update_uri_params(socket, params) when is_map(params) do
uri_params = Map.merge(socket.assigns.uri_params, params)
socket
|> assign(uri_params: uri_params)
|> push_patch(
to:
Routes.site_path(
socket,
:installation,
socket.assigns.domain,
uri_params
),
replace: true
)
end
@domain_change PlausibleWeb.Flows.domain_change()
defp get_installation_type(@domain_change, tracker_script_configuration, params) do
case tracker_script_configuration.installation_type do
nil ->
get_installation_type(nil, nil, params)
installation_type ->
Atom.to_string(installation_type)
end
end
defp get_installation_type(_type, _tracker_script_configuration, params) do
Enum.find(@installation_types, &(&1 == params["installation_type"]))
end
defp tracker_url(script_config) do
extensions =
@script_extension_params
|> Enum.flat_map(fn {key, extension} ->
if(Map.get(script_config, key), do: [extension], else: [])
end)
tracker = Enum.join(["script" | extensions], ".")
"#{PlausibleWeb.Endpoint.url()}/js/#{tracker}.js"
end
defp persist_tracker_script_configuration(socket) do
tracker_script_config_update =
Map.merge(socket.assigns.config, %{
"site_id" => socket.assigns.site.id,
"installation_type" => socket.assigns.installation_type
tracker_script_configuration =
PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{
outbound_links: true,
form_submissions: true,
file_downloads: true,
track_404_pages: true,
installation_type: recommended_installation_type
})
PlausibleWeb.Tracker.update_script_configuration!(
socket.assigns.site,
tracker_script_config_update,
:installation
)
selected_installation_type =
cond do
params["type"] in PlausibleWeb.Tracker.supported_installation_types() ->
params["type"]
socket
end
flow == Flows.review() and
not is_nil(tracker_script_configuration.installation_type) ->
Atom.to_string(tracker_script_configuration.installation_type)
defp snippet_change_flash(old_config, new_config) do
change =
Enum.find(new_config, fn {key, new_value} ->
Map.get(old_config, key) != new_value
end)
true ->
recommended_installation_type
end
case change do
nil ->
nil
{k, false} when k in ["outbound_links", "file_downloads", "track_404_pages"] ->
"Snippet updated and goal deleted. Please insert the newest snippet into your site"
{_, _} ->
"Snippet updated. Please insert the newest snippet into your site"
end
{:ok,
%{
recommended_installation_type: recommended_installation_type,
v1_detected: v1_detected,
installation_type: selected_installation_type,
tracker_script_configuration_form:
to_form(
Plausible.Site.TrackerScriptConfiguration.installation_changeset(
tracker_script_configuration,
%{}
)
)
}}
end
end

View File

@ -1,6 +1,6 @@
defmodule PlausibleWeb.Live.InstallationV2.Icons do
defmodule PlausibleWeb.Live.Installation.Icons do
@moduledoc """
Icon components for InstallationV2 module
Icon components for the Installation module
"""
use Phoenix.Component

View File

@ -1,6 +1,6 @@
defmodule PlausibleWeb.Live.InstallationV2.Instructions do
defmodule PlausibleWeb.Live.Installation.Instructions do
@moduledoc """
Instruction forms and components for InstallationV2 module
Instruction forms and components for the Installation module
"""
use PlausibleWeb, :component

View File

@ -1,352 +0,0 @@
defmodule PlausibleWeb.Live.InstallationV2 do
@moduledoc """
User assistance module around Plausible installation instructions/onboarding
"""
use Plausible
use PlausibleWeb, :live_view
require Logger
alias PlausibleWeb.Flows
alias Phoenix.LiveView.AsyncResult
alias PlausibleWeb.Live.InstallationV2.Icons
alias PlausibleWeb.Live.InstallationV2.Instructions
on_ee do
alias Plausible.InstallationSupport.{Detection, Result}
end
def mount(
%{"domain" => domain} = params,
_session,
socket
) do
site =
Plausible.Sites.get_for_user!(socket.assigns.current_user, domain,
roles: [
:owner,
:admin,
:editor,
:super_admin,
:viewer
]
)
flow = params["flow"] || Flows.provisioning()
socket =
on_ee do
if connected?(socket) do
assign_async(
socket,
[
:recommended_installation_type,
:installation_type,
:tracker_script_configuration_form,
:v1_detected
],
fn -> initialize_installation_data(flow, site, params) end
)
else
assign_loading_states(socket)
end
else
# On Community Edition, there's no v1 detection, nor pre-installation
# site scan - we just default the pre-selected tab to "manual".
# Although it's functionally unnecessary, we stick to using `%AsyncResult{}`
# for these assigns to minimize branching out the CE code and maintain only
# a single `render` function.
{:ok, installation_data} = initialize_installation_data(flow, site, params)
assign(socket,
recommended_installation_type: %AsyncResult{
result: installation_data.recommended_installation_type,
ok?: true
},
installation_type: %AsyncResult{
result: installation_data.installation_type,
ok?: true
},
tracker_script_configuration_form: %AsyncResult{
result: installation_data.tracker_script_configuration_form,
ok?: true
},
v1_detected: %AsyncResult{
result: installation_data.v1_detected,
ok?: true
}
)
end
{:ok,
assign(socket,
site: site,
flow: flow
)}
end
def handle_params(params, _url, socket) do
socket =
if connected?(socket) && socket.assigns.recommended_installation_type.result &&
params["type"] in PlausibleWeb.Tracker.supported_installation_types() do
assign(socket,
installation_type: %AsyncResult{result: params["type"]}
)
else
socket
end
{:noreply, socket}
end
def render(assigns) do
~H"""
<div>
<PlausibleWeb.Components.FlowProgress.render flow={@flow} current_step="Install Plausible" />
<.focus_box>
<.async_result :let={recommended_installation_type} assign={@recommended_installation_type}>
<:loading>
<div class="text-center text-gray-500">
{if(@flow == Flows.review(),
do: "Scanning your site to detect how Plausible is integrated...",
else: "Determining the simplest integration path for your website..."
)}
</div>
<div class="flex items-center justify-center py-8">
<.spinner class="w-6 h-6" />
</div>
</:loading>
<div class="grid grid-cols-2 sm:flex sm:flex-row gap-2 bg-gray-100 dark:bg-gray-900 rounded-md p-1">
<.tab
patch={"?type=manual&flow=#{@flow}"}
selected={@installation_type.result == "manual"}
>
<Icons.script_icon /> Script
</.tab>
<.tab
patch={"?type=wordpress&flow=#{@flow}"}
selected={@installation_type.result == "wordpress"}
>
<Icons.wordpress_icon /> WordPress
</.tab>
<%= on_ee do %>
<.tab patch={"?type=gtm&flow=#{@flow}"} selected={@installation_type.result == "gtm"}>
<Icons.tag_manager_icon /> Tag Manager
</.tab>
<% end %>
<.tab patch={"?type=npm&flow=#{@flow}"} selected={@installation_type.result == "npm"}>
<Icons.npm_icon /> NPM
</.tab>
</div>
<%= on_ee do %>
<.outdated_script_notice
:if={@v1_detected.result == true}
recommended_installation_type={@recommended_installation_type}
installation_type={@installation_type}
/>
<% end %>
<.form for={@tracker_script_configuration_form.result} phx-submit="submit" class="mt-4">
<.input
type="hidden"
field={@tracker_script_configuration_form.result[:installation_type]}
value={@installation_type.result}
/>
<Instructions.manual_instructions
:if={@installation_type.result == "manual"}
tracker_script_configuration_form={@tracker_script_configuration_form.result}
/>
<Instructions.wordpress_instructions
:if={@installation_type.result == "wordpress"}
flow={@flow}
recommended_installation_type={recommended_installation_type}
/>
<%= on_ee do %>
<Instructions.gtm_instructions
:if={@installation_type.result == "gtm"}
recommended_installation_type={recommended_installation_type}
tracker_script_configuration_form={@tracker_script_configuration_form.result}
/>
<% end %>
<Instructions.npm_instructions :if={@installation_type.result == "npm"} />
<.button type="submit" class="w-full mt-8">
{verify_cta(@installation_type.result)}
</.button>
</.form>
</.async_result>
<:footer :if={ce?() and @installation_type.result == "manual"}>
<.focus_list>
<:item>
Still using the legacy snippet with the data-domain attribute? See
<.styled_link href="https://plausible.io/docs/script-update-guide">
migration guide
</.styled_link>
</:item>
</.focus_list>
</:footer>
</.focus_box>
</div>
"""
end
defp verify_cta("manual"), do: "Verify Script installation"
defp verify_cta("wordpress"), do: "Verify WordPress installation"
defp verify_cta("gtm"), do: "Verify Tag Manager installation"
defp verify_cta("npm"), do: "Verify NPM installation"
on_ee do
defp detect_recommended_installation_type(flow, site) do
with {:ok, detection_result} <-
Detection.Checks.run_with_rate_limit(nil, site.domain,
detect_v1?: flow == Flows.review(),
report_to: nil,
slowdown: 0,
async?: false
),
%Result{ok?: true, data: data} <-
Detection.Checks.interpret_diagnostics(detection_result) do
{data.suggested_technology, data.v1_detected}
else
_ -> {PlausibleWeb.Tracker.fallback_installation_type(), false}
end
end
else
defp detect_recommended_installation_type(_flow, _site) do
{PlausibleWeb.Tracker.fallback_installation_type(), false}
end
end
on_ee do
defp outdated_script_notice(assigns) do
~H"""
<div :if={
@recommended_installation_type.result == "manual" and
@installation_type.result == "manual"
}>
<.notice class="mt-4" theme={:yellow}>
Your website is running an outdated version of the tracking script. Please
<.styled_link new_tab href="https://plausible.io/docs/script-update-guide">
update
</.styled_link>
your tracking script before continuing
</.notice>
</div>
<div :if={
@recommended_installation_type.result == "gtm" and
@installation_type.result == "gtm"
}>
<.notice class="mt-4" theme={:yellow}>
Your website might be using an outdated version of our Google Tag Manager template.
If so,
<.styled_link new_tab href="https://plausible.io/docs/script-update-guide#gtm">
update
</.styled_link>
your Google Tag Manager template before continuing
</.notice>
</div>
"""
end
defp assign_loading_states(socket) do
assign(socket,
recommended_installation_type: AsyncResult.loading(),
v1_detected: AsyncResult.loading(),
installation_type: AsyncResult.loading(),
tracker_script_configuration_form: AsyncResult.loading()
)
end
end
attr :selected, :boolean, default: false
attr :patch, :string, required: true
slot :inner_block, required: true
defp tab(assigns) do
assigns =
if assigns[:selected] do
assign(assigns,
class:
"bg-white dark:bg-gray-800 rounded-md px-3.5 py-2.5 text-sm font-medium flex items-center flex-1 justify-center whitespace-nowrap"
)
else
assign(assigns,
class:
"bg-gray-100 dark:bg-gray-700 rounded-md px-3.5 py-2.5 text-sm font-medium flex items-center cursor-pointer flex-1 justify-center whitespace-nowrap"
)
end
~H"""
<.link patch={@patch} class={@class}>
{render_slot(@inner_block)}
</.link>
"""
end
def handle_event("submit", %{"tracker_script_configuration" => params}, socket) do
config =
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
)
)}
end
defp initialize_installation_data(flow, site, params) do
{recommended_installation_type, v1_detected} =
detect_recommended_installation_type(flow, site)
tracker_script_configuration =
PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{
outbound_links: true,
form_submissions: true,
file_downloads: true,
track_404_pages: true,
installation_type: recommended_installation_type
})
selected_installation_type =
cond do
params["type"] in PlausibleWeb.Tracker.supported_installation_types() ->
params["type"]
flow == Flows.review() and
not is_nil(tracker_script_configuration.installation_type) ->
Atom.to_string(tracker_script_configuration.installation_type)
true ->
recommended_installation_type
end
{:ok,
%{
recommended_installation_type: recommended_installation_type,
v1_detected: v1_detected,
installation_type: selected_installation_type,
tracker_script_configuration_form:
to_form(
Plausible.Site.TrackerScriptConfiguration.installation_changeset(
tracker_script_configuration,
%{}
)
)
}}
end
end

View File

@ -554,8 +554,6 @@ defmodule PlausibleWeb.Router do
get "/sites/new", SiteController, :new
post "/sites", SiteController, :create_site
get "/sites/:domain/change-domain", SiteController, :change_domain
put "/sites/:domain/change-domain", SiteController, :change_domain_submit
post "/sites/:domain/make-public", SiteController, :make_public
post "/sites/:domain/make-private", SiteController, :make_private
post "/sites/:domain/weekly-report/enable", SiteController, :enable_weekly_report
@ -636,12 +634,6 @@ defmodule PlausibleWeb.Router do
live "/:domain/installation", Installation, :installation, as: :site
end
scope assigns: %{
dogfood_page_path: "/:website/installationv2"
} do
live "/:domain/installationv2", InstallationV2, :installation_v2, as: :site
end
scope assigns: %{
dogfood_page_path: "/:website/verification"
} do
@ -652,10 +644,10 @@ defmodule PlausibleWeb.Router do
end
scope assigns: %{
dogfood_page_path: "/:website/change-domain-v2"
dogfood_page_path: "/:website/change-domain"
} do
live "/:domain/change-domain-v2", ChangeDomainV2, :change_domain_v2, as: :site
live "/:domain/change-domain-v2/success", ChangeDomainV2, :success, as: :site
live "/:domain/change-domain", ChangeDomain, :change_domain, as: :site
live "/:domain/change-domain/success", ChangeDomain, :success, as: :site
end
end

View File

@ -1,50 +0,0 @@
<PlausibleWeb.Components.FlowProgress.render
flow={PlausibleWeb.Flows.domain_change()}
current_step="Set up new domain"
/>
<.focus_box>
<:title>Change your website domain</:title>
<:subtitle>
Once you change your domain, you <i>must</i>
update Plausible Installation on your site within 72 hours to guarantee continuous tracking.
<br /><br />If you're using the API, please also make sure to update your API credentials. Visit our
<.styled_link new_tab href="https://plausible.io/docs/change-domain-name/">
documentation
</.styled_link>
for details.
</:subtitle>
<:footer>
<.focus_list>
<:item>
Changed your mind? Go back to
<.styled_link href={Routes.site_path(@conn, :settings_general, @site.domain)}>
Site Settings
</.styled_link>
</:item>
</.focus_list>
</:footer>
<.form
:let={f}
for={@changeset}
action={
Routes.site_path(@conn, :change_domain_submit, @site.domain,
flow: PlausibleWeb.Flows.domain_change()
)
}
>
<.input
help_text="Just the naked domain or subdomain without 'www', 'https' etc."
type="text"
placeholder="example.com"
field={f[:domain]}
label="Domain"
/>
<.button type="submit" class="mt-4 w-full">
Change Domain and add new Snippet
</.button>
</.form>
</.focus_box>

View File

@ -14,10 +14,6 @@ defmodule PlausibleWeb.Tracker do
@plausible_main_script File.read!(path)
@external_resource "priv/tracker/js/plausible-web.js"
def scriptv2?(site, user \\ nil) do
FunWithFlags.enabled?(:scriptv2, for: site) or FunWithFlags.enabled?(:scriptv2, for: user)
end
@spec get_plausible_main_script(String.t(), Keyword.t()) :: String.t() | nil
def get_plausible_main_script(id, cache_opts \\ []) do
on_ee do

View File

@ -1,36 +0,0 @@
export default async function({ page, context }) {
if (context.debug) {
page.on('console', (msg) => console[msg.type()]('PAGE LOG:', msg.text()));
}
await page.setUserAgent(context.userAgent);
await page.goto(context.url);
try {
await page.waitForFunction('window.plausible', { timeout: 5000 });
await page.evaluate(() => {
window.__plausible = true;
window.plausible('verification-agent-test', {
callback: function(options) {
window.plausibleCallbackResult = () => options && options.status ? options.status : -1;
}
});
});
try {
await page.waitForFunction('window.plausibleCallbackResult', { timeout: 5000 });
const status = await page.evaluate(() => { return window.plausibleCallbackResult() });
return { data: { plausibleInstalled: true, callbackStatus: status } };
} catch ({ err, message }) {
return { data: { plausibleInstalled: true, callbackStatus: 0, error: message } };
}
} catch ({ err, message }) {
return {
data: {
plausibleInstalled: false, callbackStatus: 0, error: message
}
};
}
}

View File

@ -1,42 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.CSPTest do
use Plausible.DataCase, async: true
on_ee do
alias Plausible.InstallationSupport.{State, LegacyVerification}
@check Plausible.InstallationSupport.Checks.CSP
@default_state %State{diagnostics: %LegacyVerification.Diagnostics{}}
test "skips no headers" do
state = @default_state
assert ^state = @check.perform(state)
end
test "skips no headers 2" do
state = @default_state |> State.assign(headers: %{})
assert ^state = @check.perform(state)
end
test "disallowed" do
headers = %{"content-security-policy" => ["default-src 'self' foo.local; example.com"]}
state =
@default_state
|> State.assign(headers: headers)
|> @check.perform()
assert state.diagnostics.disallowed_via_csp?
end
test "allowed" do
headers = %{"content-security-policy" => ["default-src 'self' example.com; localhost"]}
state =
@default_state
|> State.assign(headers: headers)
|> @check.perform()
refute state.diagnostics.disallowed_via_csp?
end
end
end

View File

@ -1,66 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.FetchBodyTest do
use Plausible.DataCase, async: true
on_ee do
import Plug.Conn
alias Plausible.InstallationSupport.{State, Checks, LegacyVerification}
@check Checks.FetchBody
@normal_body """
<html>
<head>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
setup do
{:ok,
state: %State{
url: "https://example.com",
diagnostics: %LegacyVerification.Diagnostics{}
}}
end
test "extracts document", %{state: state} do
stub()
state = @check.perform(state)
assert state.assigns.raw_body == @normal_body
assert state.assigns.document == Floki.parse_document!(@normal_body)
assert state.assigns.headers["content-type"] == ["text/html; charset=utf-8"]
assert state.diagnostics.body_fetched?
end
test "does extract on non-2xx", %{state: state} do
stub(400)
state = @check.perform(state)
assert state.diagnostics.body_fetched?
end
test "doesn't extract non-HTML", %{state: state} do
stub(200, @normal_body, "text/plain")
state = @check.perform(state)
assert state.assigns == %{final_domain: "example.com"}
refute state.diagnostics.body_fetched?
end
defp stub(f) when is_function(f, 1) do
Req.Test.stub(@check, f)
end
defp stub(status \\ 200, body \\ @normal_body, content_type \\ "text/html") do
stub(fn conn ->
conn
|> put_resp_content_type(content_type)
|> send_resp(status, body)
end)
end
end
end

View File

@ -1,84 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.ScanBodyTest do
use Plausible.DataCase, async: true
on_ee do
alias Plausible.InstallationSupport.{State, Checks, LegacyVerification}
@check Checks.ScanBody
@default_state %State{diagnostics: %LegacyVerification.Diagnostics{}}
test "skips on no raw body" do
assert @default_state = @check.perform(@default_state)
end
test "detects nothing" do
state =
@default_state
|> State.assign(raw_body: "...")
|> @check.perform()
refute state.diagnostics.gtm_likely?
refute state.diagnostics.wordpress_likely?
end
test "detects GTM" do
state =
@default_state
|> State.assign(raw_body: "...googletagmanager.com/gtm.js...")
|> @check.perform()
assert state.diagnostics.gtm_likely?
refute state.diagnostics.wordpress_likely?
refute state.diagnostics.cookie_banner_likely?
end
test "detects GTM and cookie banner" do
state =
@default_state
|> State.assign(raw_body: "...googletagmanager.com/gtm.js...cookiebot...")
|> @check.perform()
assert state.diagnostics.gtm_likely?
assert state.diagnostics.cookie_banner_likely?
refute state.diagnostics.wordpress_likely?
end
for signature <- ["wp-content", "wp-includes", "wp-json"] do
test "detects WordPress: #{signature}" do
state =
@default_state
|> State.assign(raw_body: "...#{unquote(signature)}...")
|> @check.perform()
refute state.diagnostics.gtm_likely?
assert state.diagnostics.wordpress_likely?
refute state.diagnostics.wordpress_plugin?
end
end
test "detects GTM and WordPress" do
state =
@default_state
|> State.assign(raw_body: "...googletagmanager.com/gtm.js....wp-content...")
|> @check.perform()
assert state.diagnostics.gtm_likely?
assert state.diagnostics.wordpress_likely?
refute state.diagnostics.wordpress_plugin?
end
@d """
<meta name='plausible-analytics-version' content='2.0.9' />
"""
test "detects official plugin" do
state =
@default_state
|> State.assign(raw_body: @d, document: Floki.parse_document!(@d))
|> @check.perform()
assert state.diagnostics.wordpress_likely?
assert state.diagnostics.wordpress_plugin?
end
end
end

View File

@ -1,166 +0,0 @@
defmodule Plausible.InstallationSupport.Checks.SnippetTest do
use Plausible.DataCase, async: true
on_ee do
alias Plausible.InstallationSupport.{State, Checks, LegacyVerification}
@check Checks.Snippet
@default_state %State{diagnostics: %LegacyVerification.Diagnostics{}}
test "skips when there's no document" do
state = @default_state
assert ^state = @check.perform(state)
end
@well_placed """
<head>
<script defer data-domain="example.com" event-author="Me" src="http://localhost:8000/js/script.js"></script>
</head>
"""
test "figures out well placed snippet" do
state =
@well_placed
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
refute state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
refute state.diagnostics.proxy_likely?
refute state.diagnostics.manual_script_extension?
end
@multi_domain """
<head>
<script defer data-domain="example.org,example.com,example.net" src="http://localhost:8000/js/script.js"></script>
</head>
"""
test "figures out well placed snippet in a multi-domain setup" do
state =
@multi_domain
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
refute state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
refute state.diagnostics.proxy_likely?
refute state.diagnostics.manual_script_extension?
end
@crazy """
<head>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</body>
"""
test "counts snippets" do
state =
@crazy
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 2
assert state.diagnostics.snippets_found_in_body == 3
refute state.diagnostics.manual_script_extension?
end
test "figures out data-domain mismatch" do
state =
@well_placed
|> new_state(data_domain: "example.typo")
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
assert state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
refute state.diagnostics.proxy_likely?
refute state.diagnostics.manual_script_extension?
end
@proxy_likely """
<head>
<script defer data-domain="example.com" src="http://my-domain.example.com/js/script.js"></script>
</head>
"""
test "figures out proxy likely" do
state =
@proxy_likely
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
refute state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
assert state.diagnostics.proxy_likely?
refute state.diagnostics.manual_script_extension?
end
@manual_extension """
<head>
<script defer data-domain="example.com" event-author="Me" src="http://localhost:8000/js/script.manual.js"></script>
</head>
"""
test "figures out manual script extension" do
state =
@manual_extension
|> new_state()
|> @check.perform()
assert state.diagnostics.manual_script_extension?
end
@unknown_attributes """
<head>
<script defer data-api="some" data-include="some" data-exclude="some" weird="one" data-domain="example.com" src="http://my-domain.example.com/js/script.js"></script>
</head>
"""
@valid_attributes """
<head>
<script defer type="text/javascript" data-cfasync='false' data-api="some" data-include="some" data-exclude="some" data-domain="example.com" src="http://my-domain.example.com/js/script.js"></script>
</head>
"""
test "figures out unknown attributes" do
state =
@valid_attributes
|> new_state()
|> @check.perform()
refute state.diagnostics.snippet_unknown_attributes?
state =
@unknown_attributes
|> new_state()
|> @check.perform()
assert state.diagnostics.snippet_unknown_attributes?
end
defp new_state(html, opts \\ []) do
doc = Floki.parse_document!(html)
opts =
[data_domain: "example.com"]
|> Keyword.merge(opts)
@default_state
|> struct!(opts)
|> State.assign(document: doc)
end
end
end

View File

@ -1,925 +0,0 @@
defmodule Plausible.InstallationSupport.LegacyVerification.ChecksTest do
use Plausible.DataCase, async: true
use Plausible
@moduletag :ee_only
on_ee do
alias Plausible.InstallationSupport.{State, Checks, LegacyVerification}
import ExUnit.CaptureLog
import Plug.Conn
@errors LegacyVerification.Errors.all()
@moduletag :capture_log
describe "successful verification" do
@normal_body """
<html>
<head>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "definite success" do
stub_fetch_body(200, @normal_body)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
end
test "fetching will follow 2 redirects" do
ref = :counters.new(1, [:atomics])
test = self()
Req.Test.stub(Checks.FetchBody, fn conn ->
if :counters.get(ref, 1) < 2 do
:counters.add(ref, 1, 1)
send(test, :redirect_sent)
conn
|> put_resp_header("location", "https://example.com")
|> send_resp(302, "redirecting to https://example.com")
else
conn
|> put_resp_header("content-type", "text/html")
|> send_resp(200, @normal_body)
end
end)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
assert_receive :redirect_sent
assert_receive :redirect_sent
refute_receive _
end
test "allowed via content-security-policy" do
stub_fetch_body(fn conn ->
conn
|> put_resp_header(
"content-security-policy",
Enum.random([
"default-src 'self'; script-src plausible.io; connect-src #{PlausibleWeb.Endpoint.host()}",
"default-src 'self' *.#{PlausibleWeb.Endpoint.host()}"
])
)
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body)
end)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
end
@proxied_script_body """
<html>
<head>
<script defer data-domain="example.com" src="https://proxy.example.com/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "proxied setup working OK" do
stub_fetch_body(200, @proxied_script_body)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
end
@body_no_snippet """
<html> <head> </head> <body> Hello </body> </html>
"""
test "non-standard integration where the snippet cannot be found but it works ok in headless" do
stub_fetch_body(200, @body_no_snippet)
stub_installation(200, plausible_installed(true, 202))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
end
@different_data_domain_body """
<html>
<head>
<script defer data-domain="www.example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "data-domain mismatch on redirect chain" do
ref = :counters.new(1, [:atomics])
test = self()
Req.Test.stub(Checks.FetchBody, fn conn ->
if :counters.get(ref, 1) == 0 do
:counters.add(ref, 1, 1)
send(test, :redirect_sent)
conn
|> put_resp_header("location", "https://www.example.com")
|> send_resp(302, "redirecting to https://www.example.com")
else
conn
|> put_resp_header("content-type", "text/html")
|> send_resp(200, @different_data_domain_body)
end
end)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
assert_receive :redirect_sent
end
end
describe "errors" do
test "service error - 400" do
stub_fetch_body(200, @normal_body)
stub_installation(400, %{})
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.temporary)
end
@tag :slow
test "can't fetch body but headless reports ok" do
stub_fetch_body(500, "")
stub_installation()
{_, log} =
with_log(fn ->
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
end)
assert log =~ "3 attempts left"
assert log =~ "2 attempts left"
assert log =~ "1 attempt left"
end
test "fetching will give up at 5th redirect" do
test = self()
stub_fetch_body(fn conn ->
send(test, :redirect_sent)
conn
|> put_resp_header("location", "https://example.com")
|> send_resp(302, "redirecting to https://example.com")
end)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.unreachable, url: "https://example.com")
assert_receive :redirect_sent
assert_receive :redirect_sent
assert_receive :redirect_sent
assert_receive :redirect_sent
assert_receive :redirect_sent
refute_receive _
end
@snippet_in_body """
<html>
<head>
</head>
<body>
Hello
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</body>
</html>
"""
test "detecting snippet in body" do
stub_fetch_body(200, @snippet_in_body)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.snippet_in_body)
end
@many_snippets """
<html>
<head>
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
</head>
<body>
Hello
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<!-- maybe proxy? -->
<script defer data-domain="example.com" src="https://example.com/js/script.js"></script>
</body>
</html>
"""
test "detecting many snippets" do
stub_fetch_body(200, @many_snippets)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.multiple_snippets)
end
@no_src_scripts """
<html>
<head>
<script defer data-domain="example.com"></script>
</head>
<body>
Hello
<script defer data-domain="example.com"></script>
</body>
</html>
"""
test "no src attr doesn't count as snippet" do
stub_fetch_body(200, @no_src_scripts)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.no_snippet)
end
@many_snippets_ok """
<html>
<head>
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<script defer data-domain="example.com" src="https://plausible.io/js/script.manual.js"></script>
</head>
<body>
Hello
</body>
</html>
"""
test "skipping many snippets when manual found" do
stub_fetch_body(200, @many_snippets_ok)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_ok()
end
test "detecting snippet after busting cache" do
stub_fetch_body(fn conn ->
conn = fetch_query_params(conn)
if conn.query_params["plausible_verification"] do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body)
else
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end
end)
stub_installation(fn conn ->
{:ok, body, _} = read_body(conn)
if String.contains?(body, "?plausible_verification") do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(plausible_installed()))
else
raise "Should not get here even"
end
end)
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.cache_general)
end
@normal_body_wordpress """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "detecting snippet after busting WordPress cache - no official plugin" do
stub_fetch_body(fn conn ->
conn = fetch_query_params(conn)
if conn.query_params["plausible_verification"] do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body_wordpress)
else
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end
end)
stub_installation(fn conn ->
{:ok, body, _} = read_body(conn)
if String.contains?(body, "?plausible_verification") do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(plausible_installed()))
else
raise "Should not get here even"
end
end)
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.cache_wp_no_plugin)
end
@normal_body_wordpress_official_plugin """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<meta name='plausible-analytics-version' content='2.0.9' />
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "detecting snippet after busting WordPress cache - official plugin" do
stub_fetch_body(fn conn ->
conn = fetch_query_params(conn)
if conn.query_params["plausible_verification"] do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body_wordpress_official_plugin)
else
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end
end)
stub_installation(fn conn ->
{:ok, body, _} = read_body(conn)
if String.contains?(body, "?plausible_verification") do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(plausible_installed()))
else
raise "Should not get here even"
end
end)
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.cache_wp_plugin)
end
test "detecting no snippet" do
stub_fetch_body(200, @body_no_snippet)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.no_snippet)
end
@body_no_snippet_wp """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
</head>
<body>
Hello
</body>
</html>
"""
test "detecting no snippet on a wordpress site" do
stub_fetch_body(200, @body_no_snippet_wp)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.no_snippet_wp)
end
test "disallowed via content-security-policy" do
stub_fetch_body(fn conn ->
conn
|> put_resp_header("content-security-policy", "default-src 'self' foo.local")
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body)
end)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.csp)
end
test "disallowed via content-security-policy with no snippet should make the latter a priority" do
stub_fetch_body(fn conn ->
conn
|> put_resp_header("content-security-policy", "default-src 'self' foo.local")
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.no_snippet)
end
test "running checks sends progress messages" do
stub_fetch_body(200, @normal_body)
stub_installation()
final_state = run_checks(report_to: self())
assert_receive {:check_start, {Checks.FetchBody, %State{}}}
assert_receive {:check_start, {Checks.CSP, %State{}}}
assert_receive {:check_start, {Checks.ScanBody, %State{}}}
assert_receive {:check_start, {Checks.Snippet, %State{}}}
assert_receive {:check_start, {Checks.SnippetCacheBust, %State{}}}
assert_receive {:check_start, {Checks.Installation, %State{}}}
assert_receive {:all_checks_done, %State{} = ^final_state}
refute_receive _
end
@gtm_body """
<html>
<head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','XXXX');</script>
<!-- End Google Tag Manager -->
</head>
<body>
Hello
</body>
</html>
"""
test "disallowed via content-security-policy and GTM should make CSP a priority" do
stub_fetch_body(fn conn ->
conn
|> put_resp_header("content-security-policy", "default-src 'self' foo.local")
|> put_resp_content_type("text/html")
|> send_resp(200, @gtm_body)
end)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.csp)
end
test "detecting gtm" do
stub_fetch_body(200, @gtm_body)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.gtm)
end
@gtm_body_with_cookiebot """
<html>
<head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','XXXX');</script>
<!-- End Google Tag Manager -->
<script id="Cookiebot" src="https://consent.cookiebot.com/uc.js" data-cbid="some-uuid" data-blockingmode="auto" type="text/javascript"></script>
</head>
<body>
Hello
</body>
</html>
"""
test "detecting gtm with cookie consent" do
stub_fetch_body(200, @gtm_body_with_cookiebot)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.gtm_cookie_banner)
end
test "non-html body" do
stub_fetch_body(fn conn ->
conn
|> put_resp_content_type("image/png")
|> send_resp(200, :binary.copy(<<0>>, 100))
end)
stub_installation(200, plausible_installed(false))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.unreachable, url: "https://example.com")
end
test "proxied setup, function defined but callback won't fire" do
stub_fetch_body(200, @proxied_script_body)
stub_installation(200, plausible_installed(true, 0))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.proxy_misconfigured)
end
@proxied_script_body_wordpress """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<script defer data-domain="example.com" src="https://proxy.example.com/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "proxied WordPress setup, function undefined, callback won't fire" do
stub_fetch_body(200, @proxied_script_body_wordpress)
stub_installation(200, plausible_installed(false, 0))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.proxy_wp_no_plugin)
end
test "proxied setup, function undefined, callback won't fire" do
stub_fetch_body(200, @proxied_script_body)
stub_installation(200, plausible_installed(false, 0))
result = run_checks()
interpretation = LegacyVerification.Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an error with your Plausible proxy"]
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.proxy_general)
end
test "non-proxied setup, but callback fails to fire" do
stub_fetch_body(200, @normal_body)
stub_installation(200, plausible_installed(true, 0))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.unknown)
end
@body_unknown_attributes """
<html>
<head>
<script foo="bar" defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "unknown attributes" do
stub_fetch_body(200, @body_unknown_attributes)
stub_installation(200, plausible_installed(false, 0))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.illegal_attrs_general)
end
@body_unknown_attributes_wordpress """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<script foo="bar" defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "unknown attributes for WordPress installation" do
stub_fetch_body(200, @body_unknown_attributes_wordpress)
stub_installation(200, plausible_installed(false, 0))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.illegal_attrs_wp_no_plugin)
end
@body_unknown_attributes_wordpress_official_plugin """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<meta name='plausible-analytics-version' content='2.0.9' />
<script foo="bar" defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "unknown attributes for WordPress installation - official plugin" do
stub_fetch_body(200, @body_unknown_attributes_wordpress_official_plugin)
stub_installation(200, plausible_installed(false, 0))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.illegal_attrs_wp_plugin)
end
test "callback handling not found for non-wordpress site" do
stub_fetch_body(200, @normal_body)
stub_installation(200, plausible_installed(true, -1))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.generic)
end
test "callback handling not found for wordpress site" do
stub_fetch_body(200, @normal_body_wordpress)
stub_installation(200, plausible_installed(true, -1))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.old_script_wp_no_plugin)
end
test "callback handling not found for wordpress site using our plugin" do
stub_fetch_body(200, @normal_body_wordpress_official_plugin)
stub_installation(200, plausible_installed(true, -1))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.old_script_wp_plugin)
end
test "fails due to callback status being something unlikely like 500" do
stub_fetch_body(200, @normal_body)
stub_installation(200, plausible_installed(true, 500))
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.unknown)
end
test "data-domain mismatch" do
stub_fetch_body(200, @different_data_domain_body)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.different_data_domain, domain: "example.com")
end
@many_snippets_with_domain_mismatch """
<html>
<head>
<script defer data-domain="example.org" src="https://plausible.io/js/script.js"></script>
<script defer data-domain="example.org" src="https://plausible.io/js/script.js"></script>
</head>
<body>
Hello
</body>
</html>
"""
test "prioritizes data-domain mismatch over multiple snippets" do
stub_fetch_body(200, @many_snippets_with_domain_mismatch)
stub_installation()
run_checks()
|> LegacyVerification.Checks.interpret_diagnostics()
|> assert_error(@errors.different_data_domain, domain: "example.com")
end
end
describe "unhhandled cases from sentry" do
test "APP-58: 4b1435e3f8a048eb949cc78fa578d1e4" do
%LegacyVerification.Diagnostics{
plausible_installed?: true,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
snippet_found_after_busting_cache?: false,
snippet_unknown_attributes?: false,
disallowed_via_csp?: false,
service_error: nil,
body_fetched?: true,
wordpress_likely?: true,
cookie_banner_likely?: false,
gtm_likely?: false,
callback_status: -1,
proxy_likely?: false,
manual_script_extension?: false,
data_domain_mismatch?: false,
wordpress_plugin?: false
}
|> interpret_sentry_case()
|> assert_error(@errors.old_script_wp_no_plugin)
end
test "service timeout" do
%LegacyVerification.Diagnostics{
plausible_installed?: false,
snippets_found_in_head: 1,
snippets_found_in_body: 0,
snippet_found_after_busting_cache?: false,
snippet_unknown_attributes?: false,
disallowed_via_csp?: false,
service_error: :timeout,
body_fetched?: true,
wordpress_likely?: true,
cookie_banner_likely?: false,
gtm_likely?: false,
callback_status: 0,
proxy_likely?: true,
manual_script_extension?: false,
data_domain_mismatch?: false,
wordpress_plugin?: false
}
|> interpret_sentry_case()
|> assert_error(@errors.generic)
end
test "malformed snippet code, that headless somewhat accepts" do
%LegacyVerification.Diagnostics{
plausible_installed?: true,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
snippet_found_after_busting_cache?: false,
snippet_unknown_attributes?: false,
disallowed_via_csp?: false,
service_error: nil,
body_fetched?: true,
wordpress_likely?: false,
cookie_banner_likely?: false,
gtm_likely?: false,
callback_status: 405,
proxy_likely?: false,
manual_script_extension?: false,
data_domain_mismatch?: false,
wordpress_plugin?: false
}
|> interpret_sentry_case()
|> assert_error(@errors.no_snippet)
end
test "gtm+wp detected, but likely script id attribute interfering" do
%LegacyVerification.Diagnostics{
plausible_installed?: false,
snippets_found_in_head: 1,
snippets_found_in_body: 0,
snippet_found_after_busting_cache?: false,
snippet_unknown_attributes?: true,
disallowed_via_csp?: false,
service_error: nil,
body_fetched?: true,
wordpress_likely?: true,
cookie_banner_likely?: true,
gtm_likely?: true,
callback_status: 0,
proxy_likely?: true,
manual_script_extension?: false,
data_domain_mismatch?: false,
wordpress_plugin?: true
}
|> interpret_sentry_case()
|> assert_error(@errors.illegal_attrs_wp_plugin)
end
end
defp interpret_sentry_case(diagnostics) do
diagnostics
|> LegacyVerification.Diagnostics.interpret("example.com")
|> refute_unhandled()
end
defp run_checks(extra_opts \\ []) do
LegacyVerification.Checks.run(
"https://example.com",
"example.com",
Keyword.merge([async?: false, report_to: nil, slowdown: 0], extra_opts)
)
end
defp stub_fetch_body(f) when is_function(f, 1) do
Req.Test.stub(Checks.FetchBody, f)
end
defp stub_installation(f) when is_function(f, 1) do
Req.Test.stub(Checks.Installation, f)
end
defp stub_fetch_body(status, body) do
stub_fetch_body(fn conn ->
conn
|> put_resp_content_type("text/html")
|> send_resp(status, body)
end)
end
defp stub_installation(status \\ 200, json \\ plausible_installed()) do
stub_installation(fn conn ->
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(json))
end)
end
defp plausible_installed(bool \\ true, callback_status \\ 202) do
%{
"data" => %{
"completed" => true,
"snippetsFoundInHead" => 0,
"snippetsFoundInBody" => 0,
"plausibleInstalled" => bool,
"callbackStatus" => callback_status
}
}
end
defp refute_unhandled(interpretation) do
refute interpretation.errors == [
@errors.unknown.message
]
refute interpretation.recommendations == [
@errors.unknown.recommendation
]
interpretation
end
defp assert_error(interpretation, error) do
refute interpretation.ok?
assert interpretation.errors == [
error.message
]
assert interpretation.recommendations == [
%{text: error.recommendation, url: error.url}
]
end
defp assert_error(interpretation, error, assigns) do
recommendation = EEx.eval_string(error.recommendation, assigns: assigns)
assert_error(interpretation, %{error | recommendation: recommendation})
end
defp assert_ok(interpretation) do
assert interpretation.ok?
assert interpretation.errors == []
assert interpretation.recommendations == []
end
end
end

View File

@ -71,7 +71,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do
end
end
describe "InstallationV2 check" do
describe "VerifyInstallation check" do
for status <- [200, 202] do
test "returns success if test event response status is #{status} and domain is as expected" do
verification_stub =
@ -379,7 +379,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do
end
end
describe "InstallationV2 & InstallationV2CacheBust" do
describe "VerifyInstallation & VerifyInstallationCacheBust" do
test "returns error when it 'succeeds', but only after cache bust" do
counter = :atomics.new(1, [])
@ -583,7 +583,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do
log = capture_log(fn -> Checks.interpret_diagnostics(state) end)
assert log =~ "[VERIFICATION v2] Unhandled case (data_domain='#{@expected_domain}')"
assert log =~ "[VERIFICATION] Unhandled case (data_domain='#{@expected_domain}')"
assert log =~ "test_event: %{}"
assert_receive {:telemetry_event, telemetry_event}
@ -596,7 +596,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do
log = capture_log(fn -> Checks.interpret_diagnostics(state) end)
assert log =~ "[VERIFICATION v2] Unhandled case (data_domain='#{@expected_domain}')"
assert log =~ "[VERIFICATION] Unhandled case (data_domain='#{@expected_domain}')"
assert log =~ "service_error: %{code: :browserless_timeout}"
assert_receive {:telemetry_event, telemetry_event}
@ -614,7 +614,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do
log = capture_log(fn -> Checks.interpret_diagnostics(state) end)
assert log =~ "[VERIFICATION v2] Unhandled case (data_domain='#{@expected_domain}')"
assert log =~ "[VERIFICATION] Unhandled case (data_domain='#{@expected_domain}')"
assert log =~ "service_error: %{code: :bad_browserless_response, extra: 400}"
assert_receive {:telemetry_event, telemetry_event}
@ -666,7 +666,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do
end
defp stub_verification_result(f) do
Req.Test.stub(Plausible.InstallationSupport.Checks.InstallationV2, f)
Req.Test.stub(Plausible.InstallationSupport.Checks.VerifyInstallation, f)
end
end
end

View File

@ -1,8 +1,6 @@
defmodule PlausibleWeb.Api.ExternalSitesControllerSitesCrudApiTest do
@moduledoc """
Tests for Sites create/read/update/delete API with `scriptv2` feature flag enabled.
It has overlap with some of the tests in `PlausibleWeb.Api.ExternalSitesControllerTest` test suite.
The overlapped tests from that suite can be deleted once the feature flag is enabled globally.
Tests for Sites create/read/update/delete API.
"""
use Plausible
use PlausibleWeb.ConnCase
@ -14,7 +12,6 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerSitesCrudApiTest do
setup :create_user
setup %{conn: conn, user: user} do
FunWithFlags.enable(:scriptv2, for_actor: user)
api_key = insert(:api_key, user: user, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
{:ok, api_key: api_key, conn: conn}
@ -61,6 +58,49 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerSitesCrudApiTest do
}) = response
end
test "can create more sites than their Enterprise plans is limited to (we sort the bills post-fact)",
%{conn: conn} do
user =
new_user()
|> subscribe_to_enterprise_plan(
features: [
Plausible.Billing.Feature.StatsAPI,
Plausible.Billing.Feature.SitesAPI
],
site_limit: 10
)
sites = for _ <- 1..10, do: new_site(owner: user)
assert 10 == length(sites)
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
response = json_response(conn, 200)
assert_matches ^strict_map(%{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn",
"custom_properties" => [],
"tracker_script_configuration" =>
^strict_map(%{
"id" => ^any(:string),
"installation_type" => nil,
"track_404_pages" => false,
"hash_based_routing" => false,
"outbound_links" => false,
"file_downloads" => false,
"revenue_tracking" => false,
"tagged_events" => false,
"form_submissions" => false,
"pageview_props" => false
})
}) = response
end
test "can create a site with a specific tracker script configuration", %{conn: conn} do
payload = %{
"domain" => "some-site.domain",
@ -464,6 +504,7 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerSitesCrudApiTest do
site2 = new_site(owner: user)
_unrelated_site = new_site()
_consolidated_excluded = new_site(owner: user, consolidated: true)
conn = get(conn, "/api/v1/sites")
@ -699,7 +740,7 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerSitesCrudApiTest do
end
@tag :capture_log
test "is 404 when user is not a member of the site", %{conn: conn} do
test "is 401 when user is not a member of the site", %{conn: conn} do
site = new_site()
conn = get(conn, "/api/v1/sites/" <> site.domain)

View File

@ -1,4 +1,25 @@
defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
@moduledoc """
Tests for the following endpoints
GET /api/v1/sites/teams
GET /api/v1/sites/guests
PUT /api/v1/sites/guests
DELETE /api/v1/sites/guests
PUT /api/v1/sites/shared-links
GET /api/v1/custom-props
PUT /api/v1/sites/custom-props
DELETE /api/v1/sites/custom-props/:property
GET /api/v1/goals
PUT /api/v1/sites/goals
DELETE /api/v1/sites/goals/:goal_id
Site CRUD endpoints tests are in ExternalSitesControllerSitesCrudApiTest
"""
use Plausible
use PlausibleWeb.ConnCase, async: false
use Plausible.Repo
@ -105,254 +126,6 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
end
end
describe "POST /api/v1/sites" do
test "can create a site", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn",
"custom_properties" => []
}
end
test "can't create site in a team where not permitted to", %{conn: conn, user: user} do
owner = new_user() |> subscribe_to_growth_plan()
team = owner |> team_of() |> Plausible.Teams.complete_setup()
add_member(team, user: user, role: :viewer)
conn =
post(conn, "/api/v1/sites", %{
"team_id" => team.identifier,
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert %{"error" => error} = json_response(conn, 402)
assert error =~ "API key does not have access to Sites API"
end
test "can create a site under a specific team if permitted", %{conn: conn, user: user} do
_site = new_site(owner: user)
owner =
new_user()
|> subscribe_to_enterprise_plan(
features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI]
)
team = owner |> team_of() |> Plausible.Teams.complete_setup()
add_member(team, user: user, role: :owner)
conn =
post(conn, "/api/v1/sites", %{
"team_id" => team.identifier,
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn",
"custom_properties" => []
}
assert Repo.get_by(Plausible.Site, domain: "some-site.domain").team_id == team.id
end
test "creates under a particular team when team-scoped key used", %{conn: conn, user: user} do
personal_team = user |> subscribe_to_business_plan() |> team_of()
another_team =
new_user()
|> subscribe_to_enterprise_plan(
features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI]
)
|> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn =
post(conn, "/api/v1/sites", %{
# is ignored
"team_id" => personal_team.identifier,
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn",
"custom_properties" => []
}
assert Repo.get_by(Plausible.Site, domain: "some-site.domain").team_id == another_team.id
end
test "timezone is validated", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "d"
})
assert json_response(conn, 400) == %{
"error" => "timezone: is invalid"
}
end
test "timezone defaults to Etc/UTC", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Etc/UTC",
"custom_properties" => []
}
end
test "domain is required", %{conn: conn} do
conn = post(conn, "/api/v1/sites", %{})
assert json_response(conn, 400) == %{
"error" => "domain: can't be blank"
}
end
test "accepts international domain names", %{conn: conn} do
["müllers-café.test", "音乐.cn", "до.101домен.рф/pages"]
|> Enum.each(fn idn_domain ->
conn = post(conn, "/api/v1/sites", %{"domain" => idn_domain})
assert %{"domain" => ^idn_domain} = json_response(conn, 200)
end)
end
test "validates uri breaking domains", %{conn: conn} do
["quero:café.test", "h&llo.test", "iamnotsur&about?this.com"]
|> Enum.each(fn bad_domain ->
conn = post(conn, "/api/v1/sites", %{"domain" => bad_domain})
assert %{"error" => error} = json_response(conn, 400)
assert error =~ "domain: must not contain URI reserved characters"
end)
end
test "does not allow creating more sites than the limit", %{conn: conn, user: user} do
for _ <- 1..10, do: new_site(owner: user)
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 402) == %{
"error" =>
"Your account has reached the limit of 10 sites. To unlock more sites, please upgrade your subscription."
}
end
test "cannot access with a bad API key scope", %{conn: conn, user: user} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> post("/api/v1/sites", %{"site" => %{"domain" => "domain.com"}})
assert json_response(conn, 401) == %{
"error" =>
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
}
end
end
describe "DELETE /api/v1/sites/:site_id" do
setup :create_site
setup %{user: user} do
subscribe_to_enterprise_plan(user,
features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI]
)
:ok
end
test "delete a site by its domain", %{conn: conn, site: site} do
conn = delete(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{"deleted" => true}
end
test "delete a site by its old domain after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn = delete(conn, "/api/v1/sites/" <> old_domain)
assert json_response(conn, 200) == %{"deleted" => true}
end
test "is 404 when site cannot be found", %{conn: conn} do
conn = delete(conn, "/api/v1/sites/foobar.baz")
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
@tag :capture_log
test "cannot delete a site that the user does not own", %{conn: conn, user: user} do
site = new_site()
add_guest(site, user: user, role: :editor)
conn = delete(conn, "/api/v1/sites/" <> site.domain)
assert %{"error" => error} = json_response(conn, 402)
assert error =~ "API key does not have access to Sites API"
end
test "cannot delete if team not matching team-scoped API key", %{
conn: conn,
user: user,
site: site
} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn = delete(conn, "/api/v1/sites/" <> site.domain)
assert %{"error" => error} = json_response(conn, 401)
assert error =~ "Invalid API key"
end
test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> delete("/api/v1/sites/" <> site.domain)
assert json_response(conn, 401) == %{
"error" =>
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
}
end
end
describe "PUT /api/v1/sites/shared-links" do
setup :create_site
@ -1153,149 +926,6 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
end
end
describe "GET /api/v1/sites" do
test "returns empty when there are no sites for user", %{conn: conn} do
conn = get(conn, "/api/v1/sites")
assert json_response(conn, 200) == %{
"sites" => [],
"meta" => %{
"before" => nil,
"after" => nil,
"limit" => 100
}
}
end
test "returns sites when present", %{conn: conn, user: user} do
site1 = new_site(owner: user)
site2 = new_site(owner: user)
_unrelated_site = new_site()
_consolidated_excluded = new_site(owner: user, consolidated: true)
conn = get(conn, "/api/v1/sites")
assert json_response(conn, 200) == %{
"sites" => [
%{"domain" => site2.domain, "timezone" => site2.timezone},
%{"domain" => site1.domain, "timezone" => site1.timezone}
],
"meta" => %{
"before" => nil,
"after" => nil,
"limit" => 100
}
}
end
test "returns sites where user is only a viewer", %{conn: conn, user: user} do
%{domain: owned_site_domain} = new_site(owner: user)
other_site = %{domain: other_site_domain} = new_site()
add_guest(other_site, user: user, role: :viewer)
conn = get(conn, "/api/v1/sites")
assert %{
"sites" => [
%{"domain" => ^other_site_domain},
%{"domain" => ^owned_site_domain}
]
} = json_response(conn, 200)
end
test "returns sites scoped to a given team for full memberships", %{conn: conn, user: user} do
_owned_site = new_site(owner: user)
other_site = new_site()
add_guest(other_site, user: user, role: :viewer)
other_team_site = new_site()
add_member(other_team_site.team, user: user, role: :viewer)
conn = get(conn, "/api/v1/sites?team_id=" <> other_team_site.team.identifier)
assert_matches %{
"sites" => [
%{"domain" => ^other_team_site.domain}
]
} = json_response(conn, 200)
end
test "implicitly scopes to a team for a team-scoped key", %{
conn: conn,
user: user
} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
site = new_site(team: another_team)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
_owned_site = new_site(owner: user)
other_site = new_site()
add_guest(other_site, user: user, role: :viewer)
other_team_site = new_site()
add_member(other_team_site.team, user: user, role: :viewer)
# `team_id` paramaeter is ignored
conn = get(conn, "/api/v1/sites?team_id=" <> other_team_site.team.identifier)
assert_matches %{
"sites" => [
%{"domain" => ^site.domain}
]
} = json_response(conn, 200)
end
test "handles pagination correctly", %{conn: conn, user: user} do
[
%{domain: site1_domain},
%{domain: site2_domain},
%{domain: site3_domain}
] = for _ <- 1..3, do: new_site(owner: user)
conn1 = get(conn, "/api/v1/sites?limit=2")
assert %{
"sites" => [
%{"domain" => ^site3_domain},
%{"domain" => ^site2_domain}
],
"meta" => %{
"before" => nil,
"after" => after_cursor,
"limit" => 2
}
} = json_response(conn1, 200)
conn2 = get(conn, "/api/v1/sites?limit=2&after=" <> after_cursor)
assert %{
"sites" => [
%{"domain" => ^site1_domain}
],
"meta" => %{
"before" => before_cursor,
"after" => nil,
"limit" => 2
}
} = json_response(conn2, 200)
assert is_binary(before_cursor)
end
test "lists sites for user with read-only scope", %{conn: conn, user: user} do
%{domain: site_domain} = new_site(owner: user)
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/api/v1/sites")
assert %{"sites" => [%{"domain" => ^site_domain}]} = json_response(conn, 200)
end
end
describe "GET /api/v1/sites/guests" do
test "returns empty when there are no guests for site", %{conn: conn, user: user} do
site = new_site(owner: user)
@ -1606,83 +1236,6 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
end
end
describe "GET /api/v1/sites/:site_id" do
setup :create_site
test "get a site by its domain", %{conn: conn, site: site} do
site =
site
|> Ecto.Changeset.change(allowed_event_props: ["logged_in", "author"])
|> Repo.update!()
conn = get(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{
"domain" => site.domain,
"timezone" => site.timezone,
"custom_properties" => ["logged_in", "author"]
}
end
test "get a site by old site_id after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn = get(conn, "/api/v1/sites/" <> old_domain)
assert json_response(conn, 200) == %{
"domain" => new_domain,
"timezone" => site.timezone,
"custom_properties" => []
}
end
test "get a site for user with read-only scope", %{conn: conn, user: user, site: site} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{
"domain" => site.domain,
"timezone" => site.timezone,
"custom_properties" => []
}
end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn = get(conn, "/api/v1/sites/" <> site.domain)
res = json_response(conn, 401)
assert res["error"] =~ "Invalid API key"
end
test "is 404 when site cannot be found", %{conn: conn} do
conn = get(conn, "/api/v1/sites/foobar.baz")
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
@tag :capture_log
test "is 401 when user is not a member of the site", %{conn: conn} do
site = new_site()
conn = get(conn, "/api/v1/sites/" <> site.domain)
assert %{"error" => error} = json_response(conn, 401)
assert error =~ "Invalid API key"
end
end
describe "GET /api/v1/custom-props" do
setup :create_site
@ -1938,76 +1491,5 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert error =~ "Invalid API key"
end
end
describe "PUT /api/v1/sites/:site_id" do
setup :create_site
setup %{user: user} do
subscribe_to_enterprise_plan(user,
features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI]
)
:ok
end
test "can change domain name", %{conn: conn, site: site} do
old_domain = site.domain
assert old_domain != "new.example.com"
conn =
put(conn, "/api/v1/sites/#{old_domain}", %{
"domain" => "new.example.com"
})
assert json_response(conn, 200) == %{
"domain" => "new.example.com",
"timezone" => "Etc/UTC",
"custom_properties" => []
}
site = Repo.reload!(site)
assert site.domain == "new.example.com"
assert site.domain_changed_from == old_domain
end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
old_domain = site.domain
assert old_domain != "new.example.com"
conn =
put(conn, "/api/v1/sites/#{old_domain}", %{
"domain" => "new.example.com"
})
res = json_response(conn, 401)
assert res["error"] =~ "Invalid API key"
end
test "can't make a no-op change", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/#{site.domain}", %{
"domain" => site.domain
})
assert json_response(conn, 400) == %{
"error" => "domain: New domain must be different than the current one"
}
end
test "domain parameter is required", %{conn: conn, site: site} do
conn = put(conn, "/api/v1/sites/#{site.domain}", %{})
assert json_response(conn, 400) == %{
"error" =>
"Payload must contain at least one of the parameters 'domain', 'tracker_script_configuration'"
}
end
end
end
end

View File

@ -483,30 +483,21 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "GET /:domain/installation" do
setup [:create_user, :log_in, :create_site]
test "static render - spinner determining installation type", %{
conn: conn,
site: site
} do
conn = get(conn, "/#{site.domain}/installation")
assert html_response(conn, 200) =~ "Determining installation type"
end
end
describe "GET /:domain/settings/general" do
setup [:create_user, :log_in, :create_site]
setup_patch_env(:google, client_id: "some", api_url: "https://www.googleapis.com")
test "shows settings form", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/general")
conn = get(conn, Routes.site_path(conn, :settings_general, site.domain))
resp = html_response(conn, 200)
assert resp =~ "Site timezone"
assert resp =~ "Site domain"
assert resp =~ "Change domain"
assert resp =~ Routes.site_path(conn, :change_domain, site.domain)
assert resp =~ "Site timezone"
assert resp =~ "Site installation"
end
@ -1861,104 +1852,6 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "domain change" do
setup [:create_user, :log_in, :create_site]
test "shows domain change in the settings form", %{conn: conn, site: site} do
conn = get(conn, Routes.site_path(conn, :settings_general, site.domain))
resp = html_response(conn, 200)
assert resp =~ "Site domain"
assert resp =~ "Change domain"
assert resp =~ Routes.site_path(conn, :change_domain, site.domain)
end
test "domain change form renders", %{conn: conn, site: site} do
conn = get(conn, Routes.site_path(conn, :change_domain, site.domain))
resp = html_response(conn, 200)
assert resp =~ Routes.site_path(conn, :change_domain_submit, site.domain)
assert resp =~
"Once you change your domain, you <i>must</i>\n update Plausible Installation on your site within 72 hours"
end
test "domain change form submission when no change is made", %{conn: conn, site: site} do
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => site.domain}
})
resp = html_response(conn, 200)
assert resp =~ "New domain must be different than the current one"
end
test "domain change form submission to an existing domain", %{conn: conn, site: site} do
another_site = insert(:site)
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => another_site.domain}
})
resp = html_response(conn, 200)
assert resp =~ "This domain cannot be registered"
site = Repo.reload!(site)
assert site.domain != another_site.domain
assert is_nil(site.domain_changed_from)
end
test "domain change form submission to a domain in transition period", %{
conn: conn,
site: site
} do
another_site = insert(:site, domain_changed_from: "foo.example.com")
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => "foo.example.com"}
})
resp = html_response(conn, 200)
assert resp =~ "This domain cannot be registered"
site = Repo.reload!(site)
assert site.domain != another_site.domain
assert is_nil(site.domain_changed_from)
end
test "domain change successful form submission redirects to installation", %{
conn: conn,
site: site
} do
original_domain = site.domain
new_domain = "â-example.com"
conn =
put(conn, Routes.site_path(conn, :change_domain_submit, site.domain), %{
"site" => %{"domain" => new_domain}
})
assert redirected_to(conn) ==
Routes.site_path(conn, :installation, new_domain,
flow: PlausibleWeb.Flows.domain_change()
)
site = Repo.reload!(site)
assert site.domain == new_domain
assert site.domain_changed_from == original_domain
end
test "change_domain redirects to v2 when scriptv2 flag is enabled", %{conn: conn, site: site} do
FunWithFlags.enable(:scriptv2, for_actor: site)
conn = get(conn, Routes.site_path(conn, :change_domain, site.domain))
assert redirected_to(conn) ==
Routes.site_path(conn, :change_domain_v2, site.domain)
end
end
describe "reset stats" do
setup [:create_user, :log_in, :create_site]

View File

@ -1,4 +1,4 @@
defmodule PlausibleWeb.Live.ChangeDomainV2Test do
defmodule PlausibleWeb.Live.ChangeDomainTest do
use PlausibleWeb.ConnCase, async: false
use Plausible
@ -12,7 +12,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
alias Plausible.Repo
describe "ChangeDomainV2 LiveView" do
describe "ChangeDomain LiveView" do
setup [:create_user, :log_in, :create_site]
on_ee do
@ -28,13 +28,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
end
test "mounts and renders form", %{conn: conn, site: site} do
{:ok, _lv, html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, _lv, html} = live(conn, "/#{site.domain}/change-domain")
assert html =~ "Change your website domain"
end
test "form submission when no change is made", %{conn: conn, site: site} do
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
html =
lv
@ -46,7 +46,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
test "form submission to an existing domain", %{conn: conn, site: site} do
another_site = insert(:site)
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
html =
lv
@ -62,7 +62,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
test "form submission to a domain in transition period", %{conn: conn, site: site} do
_another_site = insert(:site, domain_changed_from: "foo.example.com")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
html =
lv
@ -88,7 +88,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
original_domain = site.domain
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
@ -111,13 +111,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
original_domain = site.domain
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
assert html =~ "Domain Changed Successfully"
@ -126,7 +126,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
end
test "form validation shows error for empty domain", %{conn: conn, site: site} do
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
html =
lv
@ -137,7 +137,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
end
test "form validation shows error for invalid domain format", %{conn: conn, site: site} do
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
html =
lv
@ -148,7 +148,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
end
test "renders back to settings link with correct path", %{conn: conn, site: site} do
{:ok, _lv, html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, _lv, html} = live(conn, "/#{site.domain}/change-domain")
expected_link = Routes.site_path(conn, :settings_general, site.domain)
assert html =~ expected_link
@ -164,13 +164,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
})
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
assert html =~ "<i>must</i>"
@ -180,7 +180,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
assert element_exists?(
html,
"a[href='#{PlausibleWeb.Live.ChangeDomainV2.change_domain_docs_link()}']"
"a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']"
)
end
@ -197,13 +197,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
})
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
assert html =~ "<i>must</i>"
@ -214,7 +214,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
assert element_exists?(
html,
"a[href='#{PlausibleWeb.Live.ChangeDomainV2.change_domain_docs_link()}']"
"a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']"
)
end
@ -228,13 +228,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
})
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
refute html =~ "Additional Steps Required"
@ -243,7 +243,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
refute element_exists?(
html,
"a[href='#{PlausibleWeb.Live.ChangeDomainV2.change_domain_docs_link()}']"
"a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']"
)
end
@ -258,13 +258,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
})
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
assert html =~ "<i>must</i>"
@ -274,7 +274,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
assert element_exists?(
html,
"a[href='#{PlausibleWeb.Live.ChangeDomainV2.change_domain_docs_link()}']"
"a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']"
)
end
@ -302,13 +302,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
"wordpressPlugin" => false
})
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
assert html =~ "Additional Steps Required"
@ -322,13 +322,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
stub_detection_error()
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
assert html =~ "Additional Steps Required"
@ -343,13 +343,13 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
site: site
} do
new_domain = "new.#{site.domain}"
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain-v2")
{:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain")
lv
|> element("form")
|> render_submit(%{site: %{domain: new_domain}})
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain-v2/success")
assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success")
html = render_async(lv, 500)
notice = text_of_element(html, "div[data-testid='ce-generic-notice']")
@ -358,7 +358,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do
assert element_exists?(
html,
"a[href='#{PlausibleWeb.Live.ChangeDomainV2.change_domain_docs_link()}']"
"a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']"
)
end
end

View File

@ -6,7 +6,9 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do
import Phoenix.LiveViewTest, only: [render_component: 2]
import Plausible.Test.Support.HTML
alias Plausible.InstallationSupport.{State, LegacyVerification, Verification}
alias Plausible.InstallationSupport.{State, Verification}
@moduletag :capture_log
@component PlausibleWeb.Live.Components.Verification
@progress ~s|#verification-ui p#progress|
@ -41,9 +43,10 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do
test "renders diagnostic interpretation" do
interpretation =
LegacyVerification.Checks.interpret_diagnostics(%State{
url: "example.com",
diagnostics: %LegacyVerification.Diagnostics{}
Verification.Checks.interpret_diagnostics(%State{
url: "https://example.com",
data_domain: "example.com",
diagnostics: %Verification.Diagnostics{service_error: %{code: :domain_not_found}}
})
html =
@ -54,23 +57,20 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do
interpretation: interpretation
)
recommendations = html |> find(@recommendations) |> Enum.map(&text/1)
assert recommendations == [
"If your site is running at a different location, please manually check your integration.  Learn more"
]
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: "example.com",
diagnostics: %LegacyVerification.Diagnostics{}
url: "https://example.com",
data_domain: "example.com",
diagnostics: %Verification.Diagnostics{}
}
interpretation =
LegacyVerification.Checks.interpret_diagnostics(state)
interpretation = Verification.Checks.interpret_diagnostics(state)
html =
render_component(@component,
@ -83,7 +83,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do
)
assert element_exists?(html, @super_admin_report)
assert text_of_element(html, @super_admin_report) =~ "Snippets found in body: 0"
assert text_of_element(html, @super_admin_report) =~ "Plausible is on window: nil"
end
test "hides pulsating circle when finished, shows check circle" do

File diff suppressed because it is too large Load Diff

View File

@ -1,713 +0,0 @@
defmodule PlausibleWeb.Live.InstallationV2Test do
use PlausibleWeb.ConnCase
use Plausible
use Plausible.Test.Support.DNS
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
import Plausible.Teams.Test
alias Plausible.Site.TrackerScriptConfiguration
@migration_guide_link "https://plausible.io/docs/script-update-guide"
setup [:create_user, :log_in, :create_site]
setup %{site: site} do
FunWithFlags.enable(:scriptv2, for_actor: site)
:ok
end
describe "GET /:domain/installationv2" do
@tag :ee_only
test "renders loading installation screen on EE", %{conn: conn, site: site} do
resp = get(conn, "/#{site.domain}/installationv2") |> html_response(200)
assert resp =~ "animate-spin"
end
@tag :ce_build_only
test "no loading spinner, no GTM tab on CE", %{conn: conn, site: site} do
resp = get(conn, "/#{site.domain}/installationv2") |> html_response(200)
tabs_text = text_of_element(resp, "a[data-phx-link='patch']")
assert length(String.split(tabs_text)) == 3
assert tabs_text =~ "Script"
assert tabs_text =~ "WordPress"
assert tabs_text =~ "NPM"
refute resp =~ "animate-spin"
end
end
describe "LiveView" do
@tag :ee_only
test "detects installation type when mounted", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_detection_wordpress()
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert text(html) =~ "Verify WordPress installation"
end
@tag :ee_only
test "When ?type=wordpress URL parameter is supplied, detected type is unused", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_detection_manual()
{lv, _} = get_lv(conn, site, "?type=wordpress")
html = render_async(lv, 500)
assert text(html) =~ "Verify WordPress installation"
end
@tag :ee_only
test "When ?type=gtm URL parameter is supplied, detected type is unused", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_detection_wordpress()
{lv, _} = get_lv(conn, site, "?type=gtm")
html = render_async(lv, 500)
assert text(html) =~ "Verify Tag Manager installation"
end
@tag :ee_only
test "When ?type=npm URL parameter is supplied, detected type is unused", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_detection_wordpress()
{lv, _} = get_lv(conn, site, "?type=npm")
html = render_async(lv, 500)
assert text(html) =~ "Verify NPM installation"
end
@tag :ee_only
test "When ?type=manual URL parameter is supplied, detected type is unused", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_detection_wordpress()
{lv, _} = get_lv(conn, site, "?type=manual")
html = render_async(lv, 500)
assert text(html) =~ "Verify Script installation"
end
@tag :ee_only
test "allows switching between installation tabs (EE)", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_detection_manual()
{lv, _html} = get_lv(conn, site, "?type=manual")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
lv
|> element("a[href*=\"type=wordpress\"]")
|> render_click()
html = render(lv)
assert html =~ "Verify WordPress installation"
lv
|> element("a[href*=\"type=gtm\"]")
|> render_click()
html = render(lv)
assert html =~ "Verify Tag Manager installation"
lv
|> element("a[href*=\"type=npm\"]")
|> render_click()
html = render(lv)
assert html =~ "Verify NPM installation"
end
@tag :ce_build_only
test "allows switching between installation tabs (CE)", %{conn: conn, site: site} do
{lv, _html} = get_lv(conn, site)
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
lv
|> element("a[href*=\"type=wordpress\"]")
|> render_click()
html = render(lv)
assert html =~ "Verify WordPress installation"
end
test "manual installations has script snippet with expected ID", %{conn: conn, site: site} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _html} = get_lv(conn, site, "?type=manual&flow=review")
assert eventually(fn ->
html = render(lv)
{html =~ "Verify Script installation", html}
end)
html = render(lv)
config = Plausible.Repo.get_by!(TrackerScriptConfiguration, site_id: site.id)
assert html =~ "Privacy-friendly analytics by Plausible"
assert html =~ "/js/#{config.id}.js"
assert html =~ "async"
end
test "manual installation shows optional measurements", %{conn: conn, site: site} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _html} = get_lv(conn, site, "?type=manual&flow=review")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
assert html =~ "Optional measurements"
assert html =~ "Outbound links"
assert html =~ "File downloads"
assert html =~ "Form submissions"
end
test "manual installation shows advanced options in disclosure", %{conn: conn, site: site} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _html} = get_lv(conn, site, "?type=manual&flow=review")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
assert html =~ "Advanced options"
assert html =~ "Manual tagging"
assert html =~ "404 error pages"
assert html =~ "Hashed page paths"
assert html =~ "Custom properties"
assert html =~ "Ecommerce revenue"
end
test "toggling optional measurements updates tracker configuration", %{
conn: conn,
site: site
} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _html} = get_lv(conn, site, "?type=manual&flow=review")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
config = TrackerScriptConfiguration |> Plausible.Repo.get_by!(site_id: site.id)
assert config.outbound_links == true
assert config.file_downloads == true
assert config.form_submissions == true
lv
|> element("form[phx-submit='submit']")
|> render_submit(%{
"tracker_script_configuration" => %{
"installation_type" => "manual",
"outbound_links" => "false",
"file_downloads" => "true",
"form_submissions" => "true"
}
})
updated_config = TrackerScriptConfiguration |> Plausible.Repo.get_by!(site_id: site.id)
assert updated_config.outbound_links == false
assert updated_config.file_downloads == true
assert updated_config.form_submissions == true
end
on_ee do
for {type, expected_text} <- [
{"manual", "Verify Script installation"},
{"wordpress", "Verify WordPress installation"},
{"gtm", "Verify Tag Manager installation"},
{"npm", "Verify NPM installation"}
] do
test "submitting form with #{type} redirects to verification (EE)", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_detection_manual()
{lv, _html} = get_lv(conn, site, "?type=#{unquote(type)}")
html = render_async(lv, 500)
assert html =~ unquote(expected_text)
lv
|> element("form[phx-submit='submit']")
|> render_submit(%{
"tracker_script_configuration" => %{
"installation_type" => unquote(type),
"outbound_links" => "true",
"file_downloads" => "true",
"form_submissions" => "true"
}
})
assert_redirect(
lv,
Routes.site_path(conn, :verification, site.domain,
flow: "provisioning",
installation_type: unquote(type)
)
)
end
end
end
@tag :ce_build_only
test "submitting the form redirects to verification (CE)", %{conn: conn, site: site} do
{lv, _html} = get_lv(conn, site)
lv
|> element("form[phx-submit='submit']")
|> render_submit(%{
"tracker_script_configuration" => %{
"installation_type" => "manual",
"outbound_links" => "true",
"file_downloads" => "true",
"form_submissions" => "true"
}
})
assert_redirect(
lv,
Routes.site_path(conn, :verification, site.domain,
flow: "provisioning",
installation_type: "manual"
)
)
end
test "404 goal gets created regardless of user options", %{conn: conn, site: site} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _html} = get_lv(conn, site, "?type=manual")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
# Test with all options disabled
lv
|> element("form[phx-submit='submit']")
|> render_submit(%{
"tracker_script_configuration" => %{
"installation_type" => "manual",
"outbound_links" => "false",
"file_downloads" => "false",
"form_submissions" => "false"
}
})
# 404 goal should still be created
goals = Plausible.Goals.for_site(site)
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
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _html} = get_lv(conn, site, "?type=manual&flow=review")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
lv
|> element("form[phx-submit='submit']")
|> render_submit(%{
"tracker_script_configuration" => %{
"installation_type" => "manual",
"outbound_links" => "true",
"file_downloads" => "true",
"form_submissions" => "true"
}
})
assert_redirect(
lv,
Routes.site_path(conn, :verification, site.domain,
flow: "review",
installation_type: "manual"
)
)
end
@tag :ee_only
test "detected WordPress installation shows special message", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_detection_wordpress()
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert text(html) =~ "We've detected your website is using WordPress"
end
@tag :ee_only
test "if ratelimit for detection is exceeded, does not make detection request and falls back to recommending manual installation",
%{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
# exceed the rate limit for site detection
Plausible.RateLimit.check_rate(
Plausible.RateLimit,
"site_detection:#{site.domain}",
:timer.minutes(60),
1,
100
)
# this won't be used: if it were used, the output would be different
stub_detection_wordpress()
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
refute text(html) =~ "We've detected your website is using WordPress"
assert text(html) =~ "Verify Script installation"
end
@tag :ee_only
test "detected GTM installation shows special message", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_detection_gtm()
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert html =~ "Verify Tag Manager installation"
assert text(html) =~ "We've detected your website is using Google Tag Manager"
end
@tag :ee_only
test "detected NPM installation shows npm tab", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_detection_result(%{
"v1Detected" => false,
"gtmLikely" => false,
"npm" => true,
"wordpressLikely" => false,
"wordpressPlugin" => false
})
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert html =~ "Verify NPM installation"
end
@tag :ee_only
test "shows v1 detection warning and migration guide link for manual installation", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_detection_manual_with_v1()
{lv, _} = get_lv(conn, site, "?type=manual")
html = render_async(lv, 500)
assert text(html) =~ "Your website is running an outdated version of the tracking script"
assert element_exists?(html, "a[href='#{@migration_guide_link}']")
end
@tag :ce_build_only
test "shows v1 migration guide link for manual instructions", %{conn: conn, site: site} do
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert text(html) =~ "Still using the legacy snippet"
assert element_exists?(html, "a[href='#{@migration_guide_link}']")
end
test "does not render link to migrate guide on WordPress installation tab", %{
conn: conn,
site: site
} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_wordpress_with_v1()
end
{lv, _} = get_lv(conn, site, "?type=wordpress")
html = render_async(lv, 500)
assert html =~ "Verify WordPress installation"
refute element_exists?(html, "a[href='#{@migration_guide_link}']")
end
@tag :ee_only
test "falls back to manual installation when detection fails at dns check level", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain, [])
ExUnit.CaptureLog.capture_log(fn ->
{lv, _} = get_lv(conn, site)
assert eventually(fn ->
html = render(lv)
# Should default to manual installation when detection returns {:error, _}
{html =~ "Verify Script installation", html}
end)
end)
end
@tag :ee_only
test "falls back to manual installation when dns succeeds but detection fails", %{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_detection_error()
ExUnit.CaptureLog.capture_log(fn ->
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
# Should default to manual installation when detection returns {:error, _}
assert html =~ "Verify Script installation"
end)
end
end
describe "Authorization" do
test "requires site access permissions", %{conn: conn} do
other_user = insert(:user)
other_site = new_site(owner: other_user)
assert_raise Ecto.NoResultsError, fn ->
get_lv(conn, other_site)
end
end
test "allows viewer access to installation page", %{conn: conn, user: user} do
site = new_site()
add_guest(site, user: user, role: :viewer)
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
end
test "allows editor access to installation page", %{conn: conn, user: user} do
site = new_site()
add_guest(site, user: user, role: :editor)
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
end
end
describe "URL Parameter Handling" do
test "falls back to manual installation when invalid installation type parameter supplied",
%{
conn: conn,
site: site
} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _} = get_lv(conn, site, "?type=invalid")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
end
test "falls back to provisioning flow when invalid flow parameter supplied", %{
conn: conn,
site: site
} do
on_ee do
stub_lookup_a_records(site.domain)
stub_detection_manual()
end
{lv, _} = get_lv(conn, site, "?flow=invalid")
html = render_async(lv, 500)
assert html =~ "Verify Script installation"
end
end
describe "Detection Result Combinations" do
@describetag :ee_only
test "When GTM + Wordpress detected, GTM takes precedence", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_detection_result(%{
"v1Detected" => false,
"gtmLikely" => true,
"wordpressLikely" => true,
"wordpressPlugin" => false
})
{lv, _} = get_lv(conn, site)
html = render_async(lv, 500)
assert html =~ "Verify Tag Manager installation"
end
end
describe "Legacy Installations" do
@tag :ee_only
test "uses detected type in review flow when installation_type is nil", %{
conn: conn,
site: site
} do
_config =
PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{
installation_type: nil,
outbound_links: true,
file_downloads: false,
form_submissions: true
})
stub_lookup_a_records(site.domain)
stub_detection_wordpress()
{lv, _} = get_lv(conn, site, "?flow=review")
html = render_async(lv, 500)
assert html =~ "Verify WordPress installation"
end
end
defp stub_detection_manual do
stub_detection_result(%{
"v1Detected" => false,
"gtmLikely" => false,
"npm" => false,
"wordpressLikely" => false,
"wordpressPlugin" => false
})
end
defp stub_detection_wordpress do
stub_detection_result(%{
"v1Detected" => false,
"gtmLikely" => false,
"npm" => false,
"wordpressLikely" => true,
"wordpressPlugin" => false
})
end
defp stub_detection_gtm do
stub_detection_result(%{
"v1Detected" => false,
"gtmLikely" => true,
"npm" => false,
"wordpressLikely" => false,
"wordpressPlugin" => false
})
end
defp stub_detection_manual_with_v1 do
stub_detection_result(%{
"v1Detected" => true,
"gtmLikely" => false,
"npm" => false,
"wordpressLikely" => false,
"wordpressPlugin" => false
})
end
on_ee do
defp stub_detection_wordpress_with_v1 do
stub_detection_result(%{
"v1Detected" => true,
"gtmLikely" => false,
"npm" => false,
"wordpressLikely" => true,
"wordpressPlugin" => false
})
end
end
defp stub_detection_result(js_data) do
Req.Test.stub(Plausible.InstallationSupport.Checks.Detection, fn conn ->
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{"data" => Map.put(js_data, "completed", true)}))
end)
end
defp stub_detection_error do
Req.Test.stub(Plausible.InstallationSupport.Checks.Detection, fn conn ->
conn
|> put_resp_content_type("application/json")
|> send_resp(
200,
Jason.encode!(%{"data" => %{"error" => %{"message" => "Simulated browser error"}}})
)
end)
end
defp get_lv(conn, site, qs \\ nil) do
{:ok, lv, html} = live(conn, "/#{site.domain}/installationv2#{qs}")
{lv, html}
end
end

View File

@ -1,5 +1,7 @@
defmodule PlausibleWeb.Live.VerificationTest do
use PlausibleWeb.ConnCase, async: true
use PlausibleWeb.ConnCase
use Plausible.Test.Support.DNS
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
@ -41,8 +43,12 @@ defmodule PlausibleWeb.Live.VerificationTest do
describe "LiveView" do
@tag :ee_only
test "LiveView mounts", %{conn: conn, site: site} do
stub_fetch_body(200, "")
stub_installation()
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => false,
"error" => %{"message" => "Error"}
})
{_, html} = get_lv(conn, site)
@ -59,35 +65,50 @@ defmodule PlausibleWeb.Live.VerificationTest do
end
@tag :ee_only
test "ignores v2 verification custom URL input", %{conn: conn, site: site} do
stub_fetch_body(200, source(site.domain))
stub_installation()
test "from custom URL input form to verification", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => false,
"error" => %{"message" => "Error"}
})
# Get liveview with ?custom_url=true query param
{:ok, lv, html} =
conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true")
verifying_installation_text = "Verifying your installation"
# 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"})
# 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)
{
text_of_element(html, @awaiting) =~
"Awaiting your first pageview",
html
}
end)
html = render(lv)
assert html =~ "Success!"
assert html =~ "Awaiting your first pageview"
end
@tag :ee_only
test "eventually verifies installation", %{conn: conn, site: site} do
stub_fetch_body(200, source(site.domain))
stub_installation()
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => true,
"plausibleIsOnWindow" => true,
"plausibleIsInitialized" => true,
"testEvent" => %{
"normalizedBody" => %{
"domain" => site.domain
},
"responseStatus" => 200
}
})
{:ok, lv} = kick_off_live_verification(conn, site)
@ -112,8 +133,20 @@ defmodule PlausibleWeb.Live.VerificationTest do
build(:pageview)
])
stub_fetch_body(200, source(site.domain))
stub_installation()
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => true,
"plausibleIsOnWindow" => true,
"plausibleIsInitialized" => true,
"testEvent" => %{
"normalizedBody" => %{
"domain" => site.domain
},
"responseStatus" => 200
}
})
{:ok, lv} = kick_off_live_verification(conn, site)
@ -133,8 +166,20 @@ defmodule PlausibleWeb.Live.VerificationTest do
end
test "will redirect when first pageview arrives", %{conn: conn, site: site} do
stub_fetch_body(200, source(site.domain))
stub_installation()
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => true,
"plausibleIsOnWindow" => true,
"plausibleIsInitialized" => true,
"testEvent" => %{
"normalizedBody" => %{
"domain" => site.domain
},
"responseStatus" => 200
}
})
{:ok, lv} = kick_off_live_verification(conn, site)
@ -166,40 +211,89 @@ defmodule PlausibleWeb.Live.VerificationTest do
assert_redirect(lv, "/#{URI.encode_www_form(site.domain)}/")
end
@tag :ee_only
test "eventually fails to verify installation", %{conn: conn, site: site} do
stub_fetch_body(200, "")
stub_installation(200, plausible_installed(false))
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.",
"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.",
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)}",
%{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
{:ok, lv} = kick_off_live_verification(conn, site)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => false,
"plausibleIsOnWindow" => false,
"plausibleIsInitialized" => false
})
assert html =
eventually(fn ->
html = render(lv)
{html =~ "", html}
if unquote(saved_installation_type) do
PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{
"installation_type" => unquote(saved_installation_type)
})
end
{
text_of_element(html, @heading) =~
"We couldn't find the Plausible snippet",
html
}
end)
{:ok, lv} =
kick_off_live_verification(
conn,
site,
"?installation_type=#{unquote(installation_type_param)}"
)
assert element_exists?(html, @retry_button)
assert html =
eventually(fn ->
html = render(lv)
{html =~ "", html}
assert html =~ "Please insert the snippet into your site"
refute element_exists?(html, "#super-admin-report")
{
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))
refute element_exists?(html, "#super-admin-report")
end
end
end
defp get_lv(conn, site) do
{:ok, lv, html} = conn |> no_slowdown() |> live("/#{site.domain}/verification")
defp get_lv(conn, site, qs \\ nil) do
{:ok, lv, html} = conn |> no_slowdown() |> live("/#{site.domain}/verification#{qs}")
{lv, html}
end
defp kick_off_live_verification(conn, site) do
{:ok, lv, _html} = conn |> no_slowdown() |> no_delay() |> live("/#{site.domain}/verification")
defp kick_off_live_verification(conn, site, qs \\ nil) do
{:ok, lv, _html} =
conn |> no_slowdown() |> no_delay() |> live("/#{site.domain}/verification#{qs}")
{:ok, lv}
end
@ -211,47 +305,11 @@ defmodule PlausibleWeb.Live.VerificationTest do
Plug.Conn.put_private(conn, :delay, 0)
end
defp stub_fetch_body(f) when is_function(f, 1) do
Req.Test.stub(Plausible.InstallationSupport.Checks.FetchBody, f)
end
defp stub_installation(f) when is_function(f, 1) do
Req.Test.stub(Plausible.InstallationSupport.Checks.Installation, f)
end
defp stub_fetch_body(status, body) do
stub_fetch_body(fn conn ->
conn
|> put_resp_content_type("text/html")
|> send_resp(status, body)
end)
end
defp stub_installation(status \\ 200, json \\ plausible_installed()) do
stub_installation(fn conn ->
defp stub_verification_result(js_data) do
Req.Test.stub(Plausible.InstallationSupport.Checks.VerifyInstallation, fn conn ->
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(json))
|> send_resp(200, Jason.encode!(%{"data" => js_data}))
end)
end
defp plausible_installed(bool \\ true, callback_status \\ 202) do
%{
"data" => %{
"completed" => true,
"snippetsFoundInHead" => 0,
"snippetsFoundInBody" => 0,
"plausibleInstalled" => bool,
"callbackStatus" => callback_status
}
}
end
defp source(domain) do
"""
<head>
<script defer data-domain="#{domain}" src="http://localhost:8000/js/script.js"></script>
</head>
"""
end
end

View File

@ -1,321 +0,0 @@
defmodule PlausibleWeb.Live.VerificationV2Test do
use PlausibleWeb.ConnCase
use Plausible.Test.Support.DNS
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
@moduletag :capture_log
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|
setup %{site: site} do
FunWithFlags.enable(:scriptv2, for_actor: site)
:ok
end
describe "GET /:domain" do
@tag :ee_only
test "static verification screen renders", %{conn: conn, site: site} do
resp =
get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to)
|> 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"
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)
assert resp =~ "Awaiting your first pageview …"
end
end
describe "LiveView" do
@tag :ee_only
test "LiveView mounts", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => false,
"error" => %{"message" => "Error"}
})
{_, html} = get_lv(conn, site)
assert html =~ "Verifying your installation"
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
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => false,
"error" => %{"message" => "Error"}
})
# Get liveview with ?custom_url=true query param
{:ok, lv, html} =
conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true")
verifying_installation_text = "Verifying your installation"
# 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"})
# Should now show verification progress and hide custom URL form
assert html =~ verifying_installation_text
refute html =~ "Enter Your Custom URL"
end
@tag :ee_only
test "eventually verifies installation", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => true,
"plausibleIsOnWindow" => true,
"plausibleIsInitialized" => true,
"testEvent" => %{
"normalizedBody" => %{
"domain" => site.domain
},
"responseStatus" => 200
}
})
{:ok, lv} = kick_off_live_verification(conn, site)
assert eventually(fn ->
html = render(lv)
{
text_of_element(html, @awaiting) =~
"Awaiting your first pageview",
html
}
end)
html = render(lv)
assert html =~ "Success!"
assert html =~ "Awaiting your first pageview"
end
@tag :ee_only
test "won't await first pageview if site has pageviews", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview)
])
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => true,
"plausibleIsOnWindow" => true,
"plausibleIsInitialized" => true,
"testEvent" => %{
"normalizedBody" => %{
"domain" => site.domain
},
"responseStatus" => 200
}
})
{:ok, lv} = kick_off_live_verification(conn, site)
assert eventually(fn ->
html = render(lv)
{
text(html) =~ "Success",
html
}
end)
html = render(lv)
refute text_of_element(html, @awaiting) =~ "Awaiting your first pageview"
refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}/")
end
test "will redirect when first pageview arrives", %{conn: conn, site: site} do
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => true,
"plausibleIsOnWindow" => true,
"plausibleIsInitialized" => true,
"testEvent" => %{
"normalizedBody" => %{
"domain" => site.domain
},
"responseStatus" => 200
}
})
{:ok, lv} = kick_off_live_verification(conn, site)
assert eventually(fn ->
html = render(lv)
{
text(html) =~ "Awaiting",
html
}
end)
populate_stats(site, [
build(:pageview)
])
assert_redirect(lv, "/#{URI.encode_www_form(site.domain)}/")
end
@tag :ce_build_only
test "will redirect when first pageview arrives (ce)", %{conn: conn, site: site} do
{:ok, lv} = kick_off_live_verification(conn, site)
html = render(lv)
assert text(html) =~ "Awaiting your first pageview …"
populate_stats(site, [build(:pageview)])
assert_redirect(lv, "/#{URI.encode_www_form(site.domain)}/")
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.",
"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.",
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)}",
%{
conn: conn,
site: site
} do
stub_lookup_a_records(site.domain)
stub_verification_result(%{
"completed" => true,
"trackerIsInHtml" => false,
"plausibleIsOnWindow" => false,
"plausibleIsInitialized" => false
})
if unquote(saved_installation_type) do
PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{
"installation_type" => unquote(saved_installation_type)
})
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)
assert element_exists?(html, @retry_button)
assert html =~ htmlize_quotes(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}")
{lv, html}
end
defp kick_off_live_verification(conn, site, qs \\ nil) do
{:ok, lv, _html} =
conn |> no_slowdown() |> no_delay() |> live("/#{site.domain}/verification#{qs}")
{:ok, lv}
end
defp no_slowdown(conn) do
Plug.Conn.put_private(conn, :slowdown, 0)
end
defp no_delay(conn) do
Plug.Conn.put_private(conn, :delay, 0)
end
defp stub_verification_result(js_data) do
Req.Test.stub(Plausible.InstallationSupport.Checks.InstallationV2, fn conn ->
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{"data" => js_data}))
end)
end
end

View File

@ -38,15 +38,9 @@
"globals": {}
},
{
"name": "verifier-v1.js",
"entry_point": "installation_support/verifier-v1.js",
"output_path": "priv/tracker/installation_support/verifier-v1.js",
"globals": {}
},
{
"name": "verifier-v2.js",
"entry_point": "installation_support/verifier-v2.js",
"output_path": "priv/tracker/installation_support/verifier-v2.js",
"name": "verifier.js",
"entry_point": "installation_support/verifier.js",
"output_path": "priv/tracker/installation_support/verifier.js",
"globals": {}
}
],

View File

@ -1,38 +0,0 @@
const SELECTORS = {
// https://github.com/cavi-au/Consent-O-Matic/blob/master/rules/cookiebot.json
// We check whether any of the selectors mentioner under
// `cookiebot.detectors[0].showingMatcher[0].target.selector`
// is visible on the page.
cookiebot: [
'#CybotCookiebotDialogBodyButtonAccept',
'#CybotCookiebotDialogBody',
'#CybotCookiebotDialogBodyLevelButtonPreferences',
'#cb-cookieoverlay',
'#CybotCookiebotDialog',
'#cookiebanner'
]
}
function isVisible(element) {
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.offsetParent !== null
)
}
export function checkCookieBanner() {
for (const provider of Object.keys(SELECTORS)) {
for (const selector of SELECTORS[provider]) {
const element = document.querySelector(selector)
if (element && isVisible(element)) {
return true
}
}
}
return false
}

View File

@ -1,14 +0,0 @@
export function checkDataDomainMismatch(snippets, expectedDataDomain) {
if (!snippets || snippets.length === 0) return false
return snippets.some((snippet) => {
const scriptDataDomain = snippet.getAttribute('data-domain')
const multiple = scriptDataDomain.split(',').map((d) => d.trim())
const dataDomainMismatch = !multiple.some(
(domain) => domain.replace(/^www\./, '') === expectedDataDomain
)
return dataDomainMismatch
})
}

View File

@ -1,7 +0,0 @@
export function checkManualExtension(snippets) {
if (!snippets || snippets.length === 0) return false
return snippets.some((snippet) => {
return snippet.getAttribute('src').includes('manual.')
})
}

View File

@ -1,8 +0,0 @@
export function checkProxyLikely(snippets) {
if (!snippets || snippets.length === 0) return false
return snippets.some((snippet) => {
const src = snippet.getAttribute('src')
return src && !/^https:\/\/plausible\.io\//.test(src)
})
}

View File

@ -1,36 +0,0 @@
const KNOWN_ATTRIBUTES = [
'data-domain',
'src',
'defer',
'async',
'data-api',
'data-exclude',
'data-include',
'data-cfasync'
]
export function checkUnknownAttributes(snippets) {
if (!snippets || snippets.length === 0) return false
return snippets.some((snippet) => {
const attributes = snippet.attributes
for (let i = 0; i < attributes.length; i++) {
const attr = attributes[i]
if (attr.name === 'type' && attr.value === 'text/javascript') {
continue
}
if (attr.name.startsWith('event-')) {
continue
}
if (!KNOWN_ATTRIBUTES.includes(attr.name)) {
return true
}
}
return false
})
}

View File

@ -1,4 +1,4 @@
import { waitForPlausibleFunction } from './plausible-function-check'
import { waitForPlausibleFunction } from './wait-for-plausible-function'
import { checkWordPress } from './check-wordpress'
import { checkGTM } from './check-gtm'
import { checkNPM } from './check-npm'

View File

@ -1,62 +0,0 @@
import { runThrottledCheck } from './run-check'
export async function plausibleFunctionCheck(log) {
log('Checking for Plausible function...')
const plausibleFound = await waitForPlausibleFunction()
if (plausibleFound) {
log('Plausible function found. Executing test event...')
const callbackResult = await testPlausibleCallback(log)
log(`Test event callback response: ${callbackResult.status}`)
return { plausibleInstalled: true, callbackStatus: callbackResult.status }
} else {
log('Plausible function not found')
return { plausibleInstalled: false }
}
}
export async function waitForPlausibleFunction(timeout = 5000) {
const checkFn = (opts) => {
if (window.plausible?.l) {
return true
}
if (opts.timeout) {
return false
}
return 'continue'
}
return await runThrottledCheck(checkFn, { timeout: timeout, interval: 100 })
}
function testPlausibleCallback(log) {
return new Promise((resolve) => {
let callbackResolved = false
const callbackTimeout = setTimeout(() => {
if (!callbackResolved) {
callbackResolved = true
log('Timeout waiting for Plausible function callback')
resolve({ status: undefined })
}
}, 5000)
try {
window.plausible('verification-agent-test', {
callback: function (options) {
if (!callbackResolved) {
callbackResolved = true
clearTimeout(callbackTimeout)
resolve({ status: options && options.status ? options.status : -1 })
}
}
})
} catch (error) {
if (!callbackResolved) {
callbackResolved = true
clearTimeout(callbackTimeout)
log('Error calling plausible function:', error)
resolve({ status: -1 })
}
}
})
}

View File

@ -1,68 +0,0 @@
import { runThrottledCheck } from './run-check'
export async function waitForSnippetsV1(log) {
log('Starting snippet detection...')
let snippetCounts = await waitForFirstSnippet()
if (snippetCounts.all > 0) {
log(
`Found snippets: head=${snippetCounts.head}; body=${snippetCounts.body}`
)
log('Waiting for additional snippets to appear...')
snippetCounts = await waitForAdditionalSnippets()
log(
`Final snippet count: head=${snippetCounts.head}; body=${snippetCounts.body}`
)
} else {
log('No snippets found after 5 seconds')
}
return {
nodes: [...getHeadSnippets(), ...getBodySnippets()],
counts: snippetCounts
}
}
function getHeadSnippets() {
return document.querySelectorAll('head script[data-domain][src]')
}
function getBodySnippets() {
return document.querySelectorAll('body script[data-domain][src]')
}
function countSnippets() {
const headSnippets = getHeadSnippets()
const bodySnippets = getBodySnippets()
return {
head: headSnippets.length,
body: bodySnippets.length,
all: headSnippets.length + bodySnippets.length
}
}
async function waitForFirstSnippet() {
const checkFn = (opts) => {
const snippetsFound = countSnippets()
if (snippetsFound.all > 0 || opts.timeout) {
return snippetsFound
}
return 'continue'
}
return await runThrottledCheck(checkFn, { timeout: 5000, interval: 100 })
}
async function waitForAdditionalSnippets() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(countSnippets())
}, 1000)
})
}

View File

@ -1,76 +0,0 @@
import { waitForSnippetsV1 } from './snippet-checks'
import { plausibleFunctionCheck } from './plausible-function-check'
import { checkDataDomainMismatch } from './check-data-domain-mismatch'
import { checkProxyLikely } from './check-proxy-likely'
import { checkWordPress } from './check-wordpress'
import { checkGTM } from './check-gtm'
import { checkCookieBanner } from './check-cookie-banner'
import { checkManualExtension } from './check-manual-extension'
import { checkUnknownAttributes } from './check-unknown-attributes'
window.verifyPlausibleInstallation = async function (
expectedDataDomain,
debug
) {
function log(message) {
if (debug) console.log('[Plausible Verification]', message)
}
const [snippetData, plausibleFunctionDiagnostics] = await Promise.all([
waitForSnippetsV1(log),
plausibleFunctionCheck(log)
])
const plausibleInstalled = plausibleFunctionDiagnostics.plausibleInstalled
const callbackStatus = plausibleFunctionDiagnostics.callbackStatus || 0
const dataDomainMismatch = checkDataDomainMismatch(
snippetData.nodes,
expectedDataDomain
)
log(`dataDomainMismatch: ${dataDomainMismatch}`)
const manualScriptExtension = checkManualExtension(snippetData.nodes)
log(`manualScriptExtension: ${manualScriptExtension}`)
const unknownAttributes = checkUnknownAttributes(snippetData.nodes)
log(`unknownAttributes: ${unknownAttributes}`)
const proxyLikely = checkProxyLikely(snippetData.nodes)
log(`proxyLikely: ${proxyLikely}`)
const { wordpressPlugin, wordpressLikely } = checkWordPress(document)
log(`wordpressPlugin: ${wordpressPlugin}`)
log(`wordpressLikely: ${wordpressLikely}`)
const gtmLikely = checkGTM(document)
log(`gtmLikely: ${gtmLikely}`)
let cookieBannerLikely
if (plausibleInstalled && [200, 202].includes(callbackStatus)) {
cookieBannerLikely = false
} else {
cookieBannerLikely = checkCookieBanner()
}
log(`cookieBannerLikely: ${cookieBannerLikely}`)
return {
data: {
completed: true,
plausibleInstalled: plausibleFunctionDiagnostics.plausibleInstalled,
callbackStatus: plausibleFunctionDiagnostics.callbackStatus || 0,
snippetsFoundInHead: snippetData.counts.head,
snippetsFoundInBody: snippetData.counts.body,
dataDomainMismatch: dataDomainMismatch,
proxyLikely: proxyLikely,
wordpressPlugin: wordpressPlugin,
wordpressLikely: wordpressLikely,
gtmLikely: gtmLikely,
cookieBannerLikely: cookieBannerLikely,
manualScriptExtension: manualScriptExtension,
unknownAttributes: unknownAttributes
}
}
}

View File

@ -1,12 +1,12 @@
/** @typedef {import('../test/support/types').VerifyV2Args} VerifyV2Args */
/** @typedef {import('../test/support/types').VerifyV2Result} VerifyV2Result */
/** @typedef {import('../test/support/types').VerifierArgs} VerifierArgs */
/** @typedef {import('../test/support/types').VerifierResult} VerifierResult */
import { initializeCookieConsentEngine } from './autoconsent-to-cookies'
import { checkDisallowedByCSP } from './check-disallowed-by-csp'
/**
* Function that verifies if Plausible is installed correctly.
* @param {VerifyV2Args}
* @returns {Promise<VerifyV2Result>}
* @param {VerifierArgs}
* @returns {Promise<VerifierResult>}
*/
const DEFAULT_TRACKER_SCRIPT_SELECTOR = 'script[src^="https://plausible.io/js"]'
@ -24,7 +24,7 @@ async function verifyPlausibleInstallation(options) {
}
function log(message) {
if (debug) console.log('[VERIFICATION v2]', message)
if (debug) console.log('[VERIFICATION]', message)
}
const disallowedByCsp = checkDisallowedByCSP(responseHeaders, cspHostToCheck)

View File

@ -0,0 +1,14 @@
import { runThrottledCheck } from './run-check'
export async function waitForPlausibleFunction(timeout = 5000) {
const checkFn = (opts) => {
if (window.plausible?.l) {
return true
}
if (opts.timeout) {
return false
}
return 'continue'
}
return await runThrottledCheck(checkFn, { timeout: timeout, interval: 100 })
}

View File

@ -1,69 +0,0 @@
import { test, expect } from '@playwright/test'
import { checkDataDomainMismatch } from '../../installation_support/check-data-domain-mismatch'
function mockSnippet(dataDomain) {
return { getAttribute: (_) => dataDomain }
}
test.describe('checkDataDomainMismatch', () => {
test('returns false when no snippets provided', () => {
expect(checkDataDomainMismatch([], 'example.com')).toBe(false)
expect(checkDataDomainMismatch(null, 'example.com')).toBe(false)
expect(checkDataDomainMismatch(undefined, 'example.com')).toBe(false)
})
test('handles empty data-domain attribute', () => {
const snippets = [mockSnippet('')]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(true)
})
test('returns false when snippet data-domain matches expected domain', () => {
const snippets = [mockSnippet('example.com')]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false)
})
test('returns true when snippet data-domain does not match expected domain', () => {
const snippets = [mockSnippet('wrong.com')]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(true)
})
test('allows www. in data-domain', () => {
const snippets = [mockSnippet('www.example.com')]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false)
})
test('handles multiple domains in data-domain attribute', () => {
const snippets = [mockSnippet('example.org,example.com,example.net')]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false)
})
test('handles multiple domains with spaces in data-domain attribute', () => {
const snippets = [mockSnippet('example.org, example.com, example.net')]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false)
})
test('handles multiple domains with www prefix', () => {
const snippets = [
mockSnippet('www.example.org, www.example.com, www.example.net')
]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false)
})
test('returns true when expected domain not in multi-domain list', () => {
const snippets = [mockSnippet('example.org,example.com,example.net')]
expect(checkDataDomainMismatch(snippets, 'example.typo')).toBe(true)
})
test('handles multiple snippets - returns true if any snippet has domain mismatch', () => {
const snippets = [mockSnippet('example.com'), mockSnippet('wrong.com')]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(true)
})
test('handles multiple snippets - returns false if all snippets match', () => {
const snippets = [
mockSnippet('example.com'),
mockSnippet('example.org,example.com,example.net')
]
expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false)
})
})

View File

@ -1,45 +0,0 @@
import { test, expect } from '@playwright/test'
import { checkManualExtension } from '../../installation_support/check-manual-extension'
function mockSnippet(dataDomain) {
return { getAttribute: (_) => dataDomain }
}
test.describe('checkManualExtension', () => {
test('returns false when no snippets provided', () => {
expect(checkManualExtension([])).toBe(false)
expect(checkManualExtension(null)).toBe(false)
expect(checkManualExtension(undefined)).toBe(false)
})
test('handles empty src attribute', () => {
const snippets = [mockSnippet('')]
expect(checkManualExtension(snippets)).toBe(false)
})
test('returns true when snippet src includes manual', () => {
const snippets = [mockSnippet('https://plausible.io/js/script.manual.js')]
expect(checkManualExtension(snippets)).toBe(true)
})
test('returns false when snippet src does not include manual', () => {
const snippets = [mockSnippet('https://plausible.io/js/script.js')]
expect(checkManualExtension(snippets)).toBe(false)
})
test('handles multiple snippets - returns true if any src includes manual', () => {
const snippets = [
mockSnippet('https://plausible.io/js/plausible.manual.js'),
mockSnippet('https://plausible.io/js/plausible.js')
]
expect(checkManualExtension(snippets)).toBe(true)
})
test('handles multiple snippets - returns false if no manual snippets', () => {
const snippets = [
mockSnippet('https://plausible.io/js/plausible.outbound-links.js'),
mockSnippet('https://plausible.io/js/plausible.compat.js')
]
expect(checkManualExtension(snippets)).toBe(false)
})
})

View File

@ -1,66 +0,0 @@
import { test, expect } from '@playwright/test'
import { checkProxyLikely } from '../../installation_support/check-proxy-likely'
function mockSnippet(src) {
return { getAttribute: (_) => src }
}
test.describe('checkProxyLikely', () => {
test('returns false when no snippets provided', () => {
expect(checkProxyLikely([])).toBe(false)
expect(checkProxyLikely(null)).toBe(false)
expect(checkProxyLikely(undefined)).toBe(false)
})
test('handles empty src attribute', () => {
const snippets = [mockSnippet('')]
expect(checkProxyLikely(snippets)).toBe(false)
})
test('returns false when snippet src is official plausible.io URL', () => {
const snippets = [mockSnippet('https://plausible.io/js/plausible.js')]
expect(checkProxyLikely(snippets)).toBe(false)
})
test('returns false when snippet src is official plausible.io URL with query params', () => {
const snippets = [
mockSnippet('https://plausible.io/js/plausible.js?v=1.0.0')
]
expect(checkProxyLikely(snippets)).toBe(false)
})
test('handles similar domain names (should be true)', () => {
const snippets = [
mockSnippet('https://plausible.io.example.com/js/plausible.js')
]
expect(checkProxyLikely(snippets)).toBe(true)
})
test('returns true when snippet src is relative path', () => {
const snippets = [mockSnippet('/js/plausible.js')]
expect(checkProxyLikely(snippets)).toBe(true)
})
test('handles multiple snippets - returns true if any snippet is proxied', () => {
const snippets = [
mockSnippet('https://plausible.io/js/plausible.js'),
mockSnippet('https://analytics.example.com/js/plausible.js')
]
expect(checkProxyLikely(snippets)).toBe(true)
})
test('handles multiple snippets - returns false if all snippets are official', () => {
const snippets = [
mockSnippet('https://plausible.io/js/plausible.js'),
mockSnippet('https://plausible.io/js/plausible.outbound-links.js')
]
expect(checkProxyLikely(snippets)).toBe(false)
})
test('handles plausible.io subdomain (should be true)', () => {
const snippets = [
mockSnippet('https://staging.plausible.io/js/plausible.js')
]
expect(checkProxyLikely(snippets)).toBe(true)
})
})

View File

@ -1,114 +0,0 @@
import { test, expect } from '@playwright/test'
import { checkUnknownAttributes } from '../../installation_support/check-unknown-attributes.js'
test.describe('checkUnknownAttributes', () => {
test('returns false when no snippets', () => {
expect(checkUnknownAttributes([])).toBe(false)
expect(checkUnknownAttributes(null)).toBe(false)
expect(checkUnknownAttributes(undefined)).toBe(false)
})
test('returns false when all attributes are known', () => {
const mockSnippet = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' },
{ name: 'async', value: '' },
{ name: 'data-api', value: '/api/event' },
{ name: 'data-exclude', value: '/admin/*' },
{ name: 'data-include', value: '/blog/*' },
{ name: 'data-cfasync', value: 'false' }
]
}
expect(checkUnknownAttributes([mockSnippet])).toBe(false)
})
test('returns false when type="text/javascript" attribute is present', () => {
const mockSnippet = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' },
{ name: 'type', value: 'text/javascript' }
]
}
expect(checkUnknownAttributes([mockSnippet])).toBe(false)
})
test('returns false when event-* attributes are present', () => {
const mockSnippet = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' },
{ name: 'event-click', value: 'handler' },
{ name: 'event-load', value: 'loadHandler' }
]
}
expect(checkUnknownAttributes([mockSnippet])).toBe(false)
})
test('returns true when unknown attributes are present', () => {
const mockSnippet = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' },
{ name: 'unknown-attribute', value: 'value' }
]
}
expect(checkUnknownAttributes([mockSnippet])).toBe(true)
})
test('returns true when multiple unknown attributes are present', () => {
const mockSnippet = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' },
{ name: 'unknown-attribute-1', value: 'value1' },
{ name: 'unknown-attribute-2', value: 'value2' }
]
}
expect(checkUnknownAttributes([mockSnippet])).toBe(true)
})
test('returns true when at least one snippet has unknown attributes', () => {
const mockSnippet1 = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' }
]
}
const mockSnippet2 = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' },
{ name: 'unknown-attribute', value: 'value' }
]
}
expect(checkUnknownAttributes([mockSnippet1, mockSnippet2])).toBe(true)
})
test('returns false when all snippets have only known attributes', () => {
const mockSnippet1 = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' }
]
}
const mockSnippet2 = {
attributes: [
{ name: 'data-domain', value: 'example.com' },
{ name: 'src', value: 'https://plausible.io/js/script.js' },
{ name: 'async', value: '' }
]
}
expect(checkUnknownAttributes([mockSnippet1, mockSnippet2])).toBe(false)
})
})

View File

@ -1,734 +0,0 @@
import { test, expect } from '@playwright/test'
import { verifyV1 } from '../support/installation-support-playwright-wrappers'
import { delay } from '../support/test-utils'
import { initializePageDynamically } from '../support/initialize-page-dynamically'
import { compileFile } from '../../compiler'
const SOME_DOMAIN = 'somesite.com'
async function mockEventResponseSuccess(page, responseDelay = 0) {
await page.context().route('**/api/event', async (route) => {
if (responseDelay > 0) {
await delay(responseDelay)
}
await route.fulfill({
status: 202,
contentType: 'text/plain',
body: 'ok'
})
})
}
test.describe('v1 verifier (basic diagnostics)', () => {
test('correct installation', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
expect(result.data.snippetsFoundInBody).toBe(0)
expect(result.data.callbackStatus).toBe(202)
expect(result.data.dataDomainMismatch).toBe(false)
expect(result.data.wordpressPlugin).toBe(false)
expect(result.data.wordpressLikely).toBe(false)
expect(result.data.cookieBannerLikely).toBe(false)
expect(result.data.manualScriptExtension).toBe(false)
// `data.proxyLikely` is mostly expected to be true in tests because
// any local script src is considered a proxy. More involved behaviour
// is covered by unit tests under `check-proxy-likely.spec.js`
expect(result.data.proxyLikely).toBe(true)
})
test('handles a dynamically loaded snippet', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<html>
<head></head>
<body>
<script>
const script = document.createElement('script')
script.async = true
script.dataset.domain = '${SOME_DOMAIN}'
script.src = '/tracker/js/plausible.local.manual.js'
setTimeout(() => {
document.getElementsByTagName('head')[0].appendChild(script)
}, 500)
</script>
</body>
</html>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN,
debug: true
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
expect(result.data.snippetsFoundInBody).toBe(0)
expect(result.data.callbackStatus).toBe(202)
expect(result.data.dataDomainMismatch).toBe(false)
})
test('missing snippet', async ({ page }, { testId }) => {
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: ''
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.plausibleInstalled).toBe(false)
expect(result.data.callbackStatus).toBe(0)
expect(result.data.snippetsFoundInHead).toBe(0)
expect(result.data.snippetsFoundInBody).toBe(0)
expect(result.data.dataDomainMismatch).toBe(false)
})
test('snippet in body', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `<body>
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>
</body>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(0)
expect(result.data.snippetsFoundInBody).toBe(1)
expect(result.data.callbackStatus).toBe(202)
expect(result.data.dataDomainMismatch).toBe(false)
})
test('figures out well placed snippet in a multi-domain setup', async ({
page
}, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `<head>
<script
async
data-domain="example.org,example.com,example.net"
src="/tracker/js/plausible.local.js"
></script>
</head>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: 'example.com'
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
expect(result.data.snippetsFoundInBody).toBe(0)
expect(result.data.callbackStatus).toBe(202)
expect(result.data.dataDomainMismatch).toBe(false)
})
test('figures out well placed snippet in a multi-domain mismatch', async ({
page
}, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `<head>
<script
async
data-domain="example.org,example.com,example.net"
src="/tracker/js/plausible.local.js"
></script>
</head>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: 'example.typo'
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
expect(result.data.snippetsFoundInBody).toBe(0)
expect(result.data.callbackStatus).toBe(202)
expect(result.data.dataDomainMismatch).toBe(true)
})
test('proxyLikely is false when every snippet starts with an official plausible.io URL', async ({
page
}, { testId }) => {
const prodScriptLocation = 'https://plausible.io/js/'
mockEventResponseSuccess(page)
// We speed up the test by serving "just some script"
// (avoiding the event callback delay in verifier)
const code = await compileFile(
{
name: 'plausible.local.js',
globals: {
COMPILE_LOCAL: true,
COMPILE_PLAUSIBLE_LEGACY_VARIANT: true
}
},
{ returnCode: true }
)
await page.context().route(`${prodScriptLocation}**`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: code
})
})
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<head>
<script
async
src="${prodScriptLocation + 'script.js'}"
data-domain="${SOME_DOMAIN}"
></script>
</head>
<body>
<script
async
src="${prodScriptLocation + 'plausible.outbound-links.js'}"
data-domain="${SOME_DOMAIN}"
></script>
</body>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.proxyLikely).toBe(false)
})
test('counting snippets', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<head>
<script
async
data-domain="example.com"
src="/tracker/js/plausible.local.js"
></script>
<script
async
data-domain="example.com"
src="/tracker/js/plausible.local.js"
></script>
</head>
<body>
<script
async
data-domain="example.com"
src="/tracker/js/plausible.local.js"
></script>
<script
async
data-domain="example.com"
src="/tracker/js/plausible.local.js"
></script>
<script
async
data-domain="example.com"
src="/tracker/js/plausible.local.js"
></script>
</body>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: 'example.com'
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(2)
expect(result.data.snippetsFoundInBody).toBe(3)
expect(result.data.callbackStatus).toBe(202)
expect(result.data.dataDomainMismatch).toBe(false)
})
test('detects dataDomainMismatch', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="wrong.com"
src="/tracker/js/plausible.local.js"
></script>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: 'right.com'
})
expect(result.data.dataDomainMismatch).toBe(true)
})
test('dataDomainMismatch is false when data-domain without "www." prefix matches', async ({
page
}, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="www.right.com"
src="/tracker/js/plausible.local.js"
></script>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: 'right.com'
})
expect(result.data.dataDomainMismatch).toBe(false)
})
})
test.describe('v1 verifier (window.plausible)', () => {
test('callbackStatus is 404 when /api/event not found', async ({ page }, {
testId
}) => {
await page.context().route('**/api/event', async (route) => {
await route.fulfill({ status: 404 })
})
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(404)
})
test('callBackStatus is 0 when event request times out', async ({ page }, {
testId
}) => {
mockEventResponseSuccess(page, 20000)
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(0)
})
test('callBackStatus is -1 when a network error occurs on sending event', async ({
page
}, { testId }) => {
await page.context().route('**/api/event', async (route) => {
await route.abort()
})
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(-1)
})
})
test.describe('v1 verifier (WordPress detection)', () => {
test('if wordpress plugin detected, wordpressLikely is also true', async ({
page
}, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<head>
<meta name="plausible-analytics-version" content="2.3.1" />
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>
</head>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.wordpressPlugin).toBe(true)
expect(result.data.wordpressLikely).toBe(true)
})
test('detects wordpressLikely by wp signatures', async ({ page }, {
testId
}) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<head>
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>
</head>
<body>
<script src="/wp-content/themes/mytheme/script.js"></script>
</body>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.wordpressPlugin).toBe(false)
expect(result.data.wordpressLikely).toBe(true)
})
})
test.describe('v1 verifier (GTM detection)', () => {
test('detects GTM', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<html>
<head>
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>
<!-- Google Tag Manager -->
<script>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js'
})
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'XXXX')
</script>
<!-- End Google Tag Manager -->
</head>
<body>
Hello
</body>
</html>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.gtmLikely).toBe(true)
})
})
test.describe('v1 verifier (cookieBanner detection)', () => {
test('detects a dynamically loaded cookiebot', async ({ page }, {
testId
}) => {
// While in real world the plausible script would be prevented
// from loading when cookiebot is present, to speed up the test
// we let it load, but mock a general network error. That is to
// avoid the a 202 response which skips cookiebot detection.
await page.context().route('**/api/event', async (route) => {
// To make sure the banner gets dynamically loaded before the
// event callback finishes, we mock a 1s delay before aborting.
await delay(1000)
await route.abort()
})
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<html>
<head>
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>
</head>
<body>
<script>
setTimeout(() => {
const banner = document.createElement('div')
banner.id = 'CybotCookiebotDialog'
document.body.appendChild(banner)
}, 500)
</script>
</body>
</html>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.cookieBannerLikely).toBe(true)
})
})
test.describe('v1 verifier (manualScriptExtension detection)', () => {
test('manualScriptExtension is true when any snippet src has "manual." in it', async ({
page
}, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<html>
<head>
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.hash.js"
></script>
</head>
<body>
<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.manual.js"
></script>
</body>
</html>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.manualScriptExtension).toBe(true)
})
})
test.describe('v1 verifier (unknownAttributes detection)', () => {
test('unknownAttributes is false when all attrs are known', async ({ page }, {
testId
}) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<html>
<head>
<script
async
type="text/javascript"
data-cfasync="false"
data-api="some"
data-include="some"
data-exclude="some"
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.manual.js"
></script>
</head>
</html>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.unknownAttributes).toBe(false)
})
test('unknownAttributes is true when any unknown attributes are present', async ({
page
}, { testId }) => {
await mockEventResponseSuccess(page)
const { url } = await initializePageDynamically(page, {
testId,
response: /* HTML */ `
<html>
<head>
<script
async
weird="one"
data-domain="${SOME_DOMAIN}"
src="/tracker/js/script.js"
></script>
</head>
</html>
`
})
const result = await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN
})
expect(result.data.unknownAttributes).toBe(true)
})
})
test.describe('v1 verifier (logging)', () => {
test('console logs in debug mode', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
let logs = []
page
.context()
.on('console', (msg) => msg.type() === 'log' && logs.push(msg.text()))
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>`
})
await verifyV1(page, {
url: url,
expectedDataDomain: SOME_DOMAIN,
debug: true
})
expect(
logs.find((str) => str.includes('Starting snippet detection'))
).toContain('[Plausible Verification] Starting snippet detection')
expect(
logs.find((str) => str.includes('Checking for Plausible function'))
).toContain('[Plausible Verification] Checking for Plausible function')
})
test('does not log by default', async ({ page }, { testId }) => {
await mockEventResponseSuccess(page)
let logs = []
page
.context()
.on('console', (msg) => msg.type() === 'log' && logs.push(msg.text()))
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: /* HTML */ `<script
async
data-domain="${SOME_DOMAIN}"
src="/tracker/js/plausible.local.js"
></script>`
})
await verifyV1(page, { url: url, expectedDataDomain: SOME_DOMAIN })
expect(logs.length).toBe(0)
})
})

View File

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'
import { executeVerifyV2 } from '../support/installation-support-playwright-wrappers'
import { executeVerifier } from '../support/installation-support-playwright-wrappers'
import { initializePageDynamically } from '../support/initialize-page-dynamically'
import { mockManyRequests } from '../support/mock-many-requests'
import { LOCAL_SERVER_ADDR } from '../support/server'
@ -46,7 +46,7 @@ test.describe('installed plausible web variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -108,7 +108,7 @@ test.describe('installed plausible web variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders,
timeoutMs
@ -168,7 +168,7 @@ test.describe('installed plausible web variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -216,7 +216,7 @@ test.describe('installed plausible web variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -270,7 +270,7 @@ test.describe('installed plausible web variant', () => {
await expect(page.getByText('alfa')).toBeVisible()
const [result, _] = await Promise.all([
executeVerifyV2(page, {
executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
timeoutMs: 1500,
responseHeaders
@ -350,7 +350,7 @@ test.describe('installed plausible web variant', () => {
await expect(page.getByText('alfa')).toBeVisible()
const [result] = await Promise.all([
executeVerifyV2(page, {
executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
timeoutMs: 1500,
timeoutBetweenAttemptsMs: 100,
@ -426,7 +426,7 @@ test.describe('installed plausible web variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -488,7 +488,7 @@ test.describe('installed plausible web variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
cspHostToCheck,
responseHeaders
@ -551,7 +551,7 @@ test.describe('installed plausible web variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
cspHostToCheck,
responseHeaders
@ -616,7 +616,7 @@ test.describe('installed plausible esm variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -681,7 +681,7 @@ test.describe('installed plausible esm variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -746,7 +746,7 @@ test.describe('installed plausible esm variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -800,7 +800,7 @@ test.describe('installed plausible esm variant', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -861,7 +861,7 @@ test.describe('installed legacy .compat script', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
responseHeaders
})
@ -929,7 +929,7 @@ test.describe('opts in on cookie banners', () => {
const response = await page.goto(url)
const responseHeaders = response?.headers() ?? {}
const result = await executeVerifyV2(page, {
const result = await executeVerifier(page, {
...DEFAULT_VERIFICATION_OPTIONS,
timeoutMs: 2000,
responseHeaders

View File

@ -1,28 +1,25 @@
import { compileFile } from '../../compiler/index.js'
import variantsFile from '../../compiler/variants.json' with { type: 'json' }
import { Page } from '@playwright/test'
import { VerifyV2Args, VerifyV2Result } from './types'
import { VerifierArgs, VerifierResult } from './types'
const VERIFIER_V1_JS_VARIANT = variantsFile.manualVariants.find(
(variant) => variant.name === 'verifier-v1.js'
)
const VERIFIER_V2_JS_VARIANT = variantsFile.manualVariants.find(
(variant) => variant.name === 'verifier-v2.js'
const VERIFIER_JS_VARIANT = variantsFile.manualVariants.find(
(variant) => variant.name === 'verifier.js'
)
const DETECTOR_JS_VARIANT = variantsFile.manualVariants.find(
(variant) => variant.name === 'detector.js'
)
export async function executeVerifyV2(
export async function executeVerifier(
page: Page,
{
responseHeaders,
maxAttempts,
timeoutBetweenAttemptsMs,
...functionContext
}: VerifyV2Args & { maxAttempts: number; timeoutBetweenAttemptsMs: number }
): Promise<VerifyV2Result> {
const verifierCode = (await compileFile(VERIFIER_V2_JS_VARIANT, {
}: VerifierArgs & { maxAttempts: number; timeoutBetweenAttemptsMs: number }
): Promise<VerifierResult> {
const verifierCode = (await compileFile(VERIFIER_JS_VARIANT, {
returnCode: true
})) as string
@ -75,26 +72,6 @@ export async function executeVerifyV2(
}
}
export async function verifyV1(page, context) {
const { url, expectedDataDomain } = context
const debug = context.debug ? true : false
const verifierCode = await compileFile(VERIFIER_V1_JS_VARIANT, {
returnCode: true
})
await page.goto(url)
await page.evaluate(verifierCode)
return await page.evaluate(
async ({ expectedDataDomain, debug }) => {
// @ts-expect-error - window.verifyPlausibleInstallation has been injected
return await window.verifyPlausibleInstallation(expectedDataDomain, debug)
},
{ expectedDataDomain, debug }
)
}
export async function detect(page, context) {
const { url, detectV1, timeoutMs } = context
const debug = context.debug ? true : false

View File

@ -17,7 +17,7 @@ export type ScriptConfig = {
endpoint: string
} & Partial<Options>
export type VerifyV2Args = {
export type VerifierArgs = {
debug: boolean
responseHeaders: Record<string, string>
timeoutMs: number
@ -43,7 +43,7 @@ type ConsentResult =
engineLifecycle: string
}
export type VerifyV2Result = {
export type VerifierResult = {
data:
| {
completed: true