diff --git a/lib/plausible/installation_support/checks/installation_v2.ex b/lib/plausible/installation_support/checks/installation_v2.ex
new file mode 100644
index 0000000000..0928b845ee
--- /dev/null
+++ b/lib/plausible/installation_support/checks/installation_v2.ex
@@ -0,0 +1,127 @@
+defmodule Plausible.InstallationSupport.Checks.InstallationV2 do
+ require Logger
+
+ path = Application.app_dir(:plausible, "priv/tracker/installation_support/verifier-v2.js")
+ # On CI, the file might not be present for static checks so we create an empty one
+ File.touch!(path)
+
+ @verifier_code File.read!(path)
+ @external_resource "priv/tracker/installation_support/verifier-v2.js"
+
+ @function_check_timeout 10_000
+
+ # Puppeteer wrapper function that executes the vanilla JS verifier code
+ @puppeteer_wrapper_code """
+ export default async function({ page, context }) {
+ try {
+ await page.setUserAgent(context.userAgent);
+ const response = await page.goto(context.url);
+
+ await page.evaluate(() => {
+ #{@verifier_code}
+ });
+
+ return await page.evaluate(async ({ responseHeaders, debug, timeoutMs, cspHostToCheck }) => {
+ return await window.verifyPlausibleInstallation({ responseHeaders, debug, timeoutMs, cspHostToCheck });
+ }, {
+ timeoutMs: context.timeoutMs,
+ responseHeaders: response.headers(),
+ debug: context.debug,
+ cspHostToCheck: context.cspHostToCheck
+ });
+ } catch (error) {
+ return {
+ data: {
+ completed: false,
+ error: {
+ message: error?.message ?? JSON.stringify(error),
+ }
+ }
+ }
+ }
+ }
+ """
+
+ @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).
+ """
+ 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} = state) do
+ opts = [
+ headers: %{content_type: "application/json"},
+ body:
+ JSON.encode!(%{
+ code: @puppeteer_wrapper_code,
+ context: %{
+ cspHostToCheck: PlausibleWeb.Endpoint.host(),
+ timeoutMs: @function_check_timeout,
+ 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(Plausible.InstallationSupport.browserless_function_api_endpoint(), opts) do
+ {:ok, %{body: body, status: status}} ->
+ handle_browserless_response(state, body, status)
+
+ {:error, %{reason: reason}} ->
+ Logger.warning(warning_message("Browserless request error: #{inspect(reason)}", state))
+
+ put_diagnostics(state, service_error: reason)
+ end
+ end
+
+ defp handle_browserless_response(
+ state,
+ %{"data" => %{"completed" => completed} = data},
+ _status
+ ) do
+ if completed do
+ put_diagnostics(
+ state,
+ disallowed_by_csp: data["disallowedByCsp"],
+ plausible_is_on_window: data["plausibleIsOnWindow"],
+ plausible_is_initialized: data["plausibleIsInitialized"],
+ plausible_version: data["plausibleVersion"],
+ plausible_variant: data["plausibleVariant"],
+ test_event: data["testEvent"],
+ cookie_banner_likely: data["cookieBannerLikely"],
+ service_error: nil
+ )
+ else
+ Logger.warning(
+ warning_message(
+ "Browserless function returned with completed: false, error.message: #{inspect(data["error"]["message"])}",
+ state
+ )
+ )
+
+ put_diagnostics(state, service_error: data["error"]["message"])
+ end
+ end
+
+ defp handle_browserless_response(state, _body, status) do
+ error = "Unhandled browserless response with status: #{status}"
+ Logger.warning(warning_message(error, state))
+
+ put_diagnostics(state, service_error: error)
+ end
+
+ defp warning_message(message, state) do
+ "[VERIFICATION v2] #{message} (data_domain='#{state.data_domain}')"
+ end
+end
diff --git a/lib/plausible/installation_support/checks/installation_v2_cache_bust.ex b/lib/plausible/installation_support/checks/installation_v2_cache_bust.ex
new file mode 100644
index 0000000000..70cf3cc736
--- /dev/null
+++ b/lib/plausible/installation_support/checks/installation_v2_cache_bust.ex
@@ -0,0 +1,39 @@
+defmodule Plausible.InstallationSupport.Checks.InstallationV2CacheBust 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.
+
+ Whatever the result from the rerun, that is what we use to interpret the installation.
+
+ The idea is to make sure that any issues we detect will be about the latest version of their website.
+
+ We also want to avoid reporting a successful installation if it took a special cache-busting action to make it work.
+ """
+
+ require Logger
+ alias Plausible.InstallationSupport
+ 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} = state) do
+ if InstallationSupport.Verification.Checks.interpret_diagnostics(state) ==
+ %InstallationSupport.Result{ok?: true} do
+ state
+ else
+ url_that_maybe_busts_cache =
+ Plausible.InstallationSupport.URL.bust_url(url)
+
+ state_after_cache_bust =
+ Plausible.InstallationSupport.Checks.InstallationV2.perform(%{
+ state
+ | url: url_that_maybe_busts_cache
+ })
+
+ put_diagnostics(state_after_cache_bust, diagnostics_are_from_cache_bust: true)
+ end
+ end
+end
diff --git a/lib/plausible/installation_support/legacy_verification/diagnostics.ex b/lib/plausible/installation_support/legacy_verification/diagnostics.ex
index 10e1eb38c2..54204f1b9f 100644
--- a/lib/plausible/installation_support/legacy_verification/diagnostics.ex
+++ b/lib/plausible/installation_support/legacy_verification/diagnostics.ex
@@ -25,14 +25,7 @@ defmodule Plausible.InstallationSupport.LegacyVerification.Diagnostics do
@type t :: %__MODULE__{}
- defmodule Result do
- @moduledoc """
- Diagnostics interpretation result.
- """
- defstruct ok?: false, errors: [], recommendations: []
- @type t :: %__MODULE__{}
- end
-
+ alias Plausible.InstallationSupport.Result
@spec interpret(t(), String.t()) :: Result.t()
def interpret(
%__MODULE__{
diff --git a/lib/plausible/installation_support/result.ex b/lib/plausible/installation_support/result.ex
new file mode 100644
index 0000000000..71bf50cbb4
--- /dev/null
+++ b/lib/plausible/installation_support/result.ex
@@ -0,0 +1,7 @@
+defmodule Plausible.InstallationSupport.Result do
+ @moduledoc """
+ Diagnostics interpretation result.
+ """
+ defstruct ok?: false, errors: [], recommendations: []
+ @type t :: %__MODULE__{}
+end
diff --git a/lib/plausible/installation_support/state.ex b/lib/plausible/installation_support/state.ex
index c68917e250..6f5040caca 100644
--- a/lib/plausible/installation_support/state.ex
+++ b/lib/plausible/installation_support/state.ex
@@ -12,7 +12,9 @@ defmodule Plausible.InstallationSupport.State do
assigns: %{},
diagnostics: %{}
- @type diagnostics_type :: Plausible.InstallationSupport.LegacyVerification.Diagnostics.t()
+ @type diagnostics_type ::
+ Plausible.InstallationSupport.LegacyVerification.Diagnostics.t()
+ | Plausible.InstallationSupport.Verification.Diagnostics.t()
@type t :: %__MODULE__{
url: String.t() | nil,
diff --git a/lib/plausible/installation_support/verification/checks.ex b/lib/plausible/installation_support/verification/checks.ex
new file mode 100644
index 0000000000..d69572713e
--- /dev/null
+++ b/lib/plausible/installation_support/verification/checks.ex
@@ -0,0 +1,47 @@
+defmodule Plausible.InstallationSupport.Verification.Checks do
+ @moduledoc """
+ Checks that are performed during tracker script installation verification.
+
+ In async execution, each check notifies the caller by sending a message to it.
+ """
+ alias Plausible.InstallationSupport.Verification
+ alias Plausible.InstallationSupport.{State, CheckRunner, Checks}
+
+ require Logger
+
+ @checks [
+ Checks.InstallationV2,
+ Checks.InstallationV2CacheBust
+ ]
+
+ def run(url, data_domain, installation_type, 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: %Verification.Diagnostics{
+ selected_installation_type: installation_type
+ }
+ }
+
+ CheckRunner.run(init_state, checks,
+ async?: async?,
+ report_to: report_to,
+ slowdown: slowdown
+ )
+ end
+
+ def interpret_diagnostics(%State{} = state) do
+ Verification.Diagnostics.interpret(
+ state.diagnostics,
+ state.data_domain,
+ state.url
+ )
+ end
+end
diff --git a/lib/plausible/installation_support/verification/diagnostics.ex b/lib/plausible/installation_support/verification/diagnostics.ex
new file mode 100644
index 0000000000..9b4d81e275
--- /dev/null
+++ b/lib/plausible/installation_support/verification/diagnostics.ex
@@ -0,0 +1,153 @@
+defmodule Plausible.InstallationSupport.Verification.Diagnostics do
+ @moduledoc """
+ Module responsible for translating diagnostics to user-friendly errors and recommendations.
+ """
+ require Logger
+
+ # in this struct, nil means indeterminate
+ defstruct selected_installation_type: nil,
+ disallowed_by_csp: nil,
+ plausible_is_on_window: nil,
+ plausible_is_initialized: nil,
+ plausible_version: nil,
+ plausible_variant: nil,
+ diagnostics_are_from_cache_bust: nil,
+ test_event: nil,
+ cookie_banner_likely: nil,
+ service_error: nil
+
+ @type t :: %__MODULE__{}
+
+ alias Plausible.InstallationSupport.Result
+
+ defmodule Error do
+ @moduledoc """
+ Error that has compile-time enforced checks for the attributes.
+ """
+
+ @enforce_keys [:message, :recommendation]
+ defstruct [:message, :recommendation, :url]
+
+ def new!(attrs) do
+ message = Map.fetch!(attrs, :message)
+
+ if String.ends_with?(message, ".") do
+ raise ArgumentError, "Error message must not end with a period: #{inspect(message)}"
+ end
+
+ if String.ends_with?(attrs[:recommendation], ".") do
+ raise ArgumentError,
+ "Error recommendation must not end with a period: #{inspect(attrs[:recommendation])}"
+ end
+
+ if is_binary(attrs[:url]) and not String.starts_with?(attrs[:url], "https://plausible.io") do
+ raise ArgumentError,
+ "Recommendation url must start with 'https://plausible.io': #{inspect(attrs[:url])}"
+ end
+
+ struct!(__MODULE__, attrs)
+ end
+ end
+
+ @error_unexpected_domain Error.new!(%{
+ message: "Plausible test event is not for this site",
+ recommendation:
+ "Please check that the snippet on your site matches the installation instructions exactly",
+ url:
+ "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"
+ })
+ @error_succeeds_only_after_cache_bust Error.new!(%{
+ message: "We detected an issue with your site's cache",
+ recommendation:
+ "Please clear the cache for your site to ensure that your visitors will load the latest version of your site that has Plausible correctly installed",
+ url:
+ "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site"
+ })
+ @spec interpret(t(), String.t(), String.t()) :: Result.t()
+ def interpret(
+ %__MODULE__{
+ plausible_is_on_window: true,
+ plausible_is_initialized: true,
+ test_event: %{
+ "normalizedBody" => %{
+ "domain" => domain
+ },
+ "responseStatus" => response_status
+ },
+ service_error: nil,
+ diagnostics_are_from_cache_bust: diagnostics_are_from_cache_bust
+ } = diagnostics,
+ expected_domain,
+ url
+ )
+ when response_status in [200, 202] do
+ domain_is_expected? = domain == expected_domain
+
+ cond do
+ domain_is_expected? and diagnostics_are_from_cache_bust ->
+ error(@error_succeeds_only_after_cache_bust)
+
+ domain_is_expected? ->
+ success()
+
+ not domain_is_expected? ->
+ error(@error_unexpected_domain)
+
+ true ->
+ unknown_error(diagnostics, url)
+ end
+ end
+
+ @error_csp_disallowed Error.new!(%{
+ message:
+ "We encountered an issue with your site's Content Security Policy (CSP)",
+ recommendation:
+ "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP",
+ url:
+ "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp"
+ })
+ def interpret(
+ %__MODULE__{
+ disallowed_by_csp: true,
+ service_error: nil
+ },
+ _expected_domain,
+ _url
+ ) do
+ error(@error_csp_disallowed)
+ end
+
+ def interpret(%__MODULE__{} = diagnostics, _expected_domain, url),
+ do: unknown_error(diagnostics, url)
+
+ defp success() do
+ %Result{ok?: true}
+ end
+
+ defp error(%Error{} = error) do
+ %Result{
+ ok?: false,
+ errors: [error.message],
+ recommendations: [%{text: error.recommendation, url: error.url}]
+ }
+ end
+
+ @unknown_error Error.new!(%{
+ 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"
+ })
+ defp unknown_error(diagnostics, url) do
+ Sentry.capture_message("Unhandled case for site verification (v2)",
+ extra: %{
+ message: inspect(diagnostics),
+ url: url,
+ hash: :erlang.phash2(diagnostics)
+ }
+ )
+
+ error(@unknown_error)
+ end
+end
diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex
index 29748d3d95..6037ddd3d4 100644
--- a/lib/plausible_web/live/components/verification.ex
+++ b/lib/plausible_web/live/components/verification.ex
@@ -7,7 +7,7 @@ defmodule PlausibleWeb.Live.Components.Verification do
use Plausible
alias PlausibleWeb.Router.Helpers, as: Routes
- alias Plausible.InstallationSupport.{State, LegacyVerification}
+ alias Plausible.InstallationSupport.{State, Result}
import PlausibleWeb.Components.Generic
@@ -21,7 +21,7 @@ defmodule PlausibleWeb.Live.Components.Verification do
attr(:finished?, :boolean, default: false)
attr(:success?, :boolean, default: false)
attr(:verification_state, State, default: nil)
- attr(:interpretation, LegacyVerification.Diagnostics.Result, default: nil)
+ attr(:interpretation, Result, default: nil)
attr(:attempts, :integer, default: 0)
attr(:flow, :string, default: "")
attr(:installation_type, :string, default: nil)
@@ -146,7 +146,8 @@ defmodule PlausibleWeb.Live.Components.Verification do
<.focus_list>
<:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}>
- {Phoenix.Naming.humanize(diag)}: {value}
+ {Phoenix.Naming.humanize(diag)}:
+ {to_string_value(value)}
@@ -157,4 +158,7 @@ defmodule PlausibleWeb.Live.Components.Verification do
"""
end
+
+ defp to_string_value(value) when is_binary(value), do: value
+ defp to_string_value(value), do: inspect(value)
end
diff --git a/lib/plausible_web/live/verification.ex b/lib/plausible_web/live/verification.ex
index 0f86b3c2a4..42e574b982 100644
--- a/lib/plausible_web/live/verification.ex
+++ b/lib/plausible_web/live/verification.ex
@@ -7,7 +7,7 @@ defmodule PlausibleWeb.Live.Verification do
use Plausible
use PlausibleWeb, :live_view
- alias Plausible.InstallationSupport.{State, LegacyVerification}
+ alias Plausible.InstallationSupport.{State, LegacyVerification, Verification}
@component PlausibleWeb.Live.Components.Verification
@slowdown_for_frequent_checking :timer.seconds(5)
@@ -131,12 +131,24 @@ defmodule PlausibleWeb.Live.Verification do
{:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking)
end
+ url_to_verify = "https://#{socket.assigns.domain}"
+ domain = socket.assigns.domain
+ installation_type = socket.assigns.installation_type
+
{:ok, pid} =
- LegacyVerification.Checks.run(
- "https://#{socket.assigns.domain}",
- socket.assigns.domain,
- report_to: report_to,
- slowdown: socket.assigns.slowdown
+ if(FunWithFlags.enabled?(:scriptv2, for: socket.assigns.site),
+ do:
+ Verification.Checks.run(url_to_verify, domain, installation_type,
+ report_to: report_to,
+ slowdown: socket.assigns.slowdown
+ ),
+ else:
+ LegacyVerification.Checks.run(
+ url_to_verify,
+ domain,
+ report_to: report_to,
+ slowdown: socket.assigns.slowdown
+ )
)
{:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)}
@@ -152,7 +164,11 @@ defmodule PlausibleWeb.Live.Verification do
end
def handle_info({:all_checks_done, %State{} = state}, socket) do
- interpretation = LegacyVerification.Checks.interpret_diagnostics(state)
+ interpretation =
+ if(FunWithFlags.enabled?(:scriptv2, for: socket.assigns.site),
+ do: Verification.Checks.interpret_diagnostics(state),
+ else: LegacyVerification.Checks.interpret_diagnostics(state)
+ )
if not socket.assigns.has_pageviews? do
schedule_pageviews_check(socket)
diff --git a/tracker/compiler/variants.json b/tracker/compiler/variants.json
index a3a27d4247..7d7d24d113 100644
--- a/tracker/compiler/variants.json
+++ b/tracker/compiler/variants.json
@@ -42,6 +42,12 @@
"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",
+ "globals": {}
}
],
"legacyVariants": [
diff --git a/tracker/installation_support/check-disallowed-by-csp.js b/tracker/installation_support/check-disallowed-by-csp.js
new file mode 100644
index 0000000000..a2c1cb8d66
--- /dev/null
+++ b/tracker/installation_support/check-disallowed-by-csp.js
@@ -0,0 +1,23 @@
+/**
+ * Checks if the CSP policy disallows the given host/domain.
+ * @param {Record} responseHeaders - Response headers with keys normalized to lowercase like { "x-foo": "bar" }
+ * @param {string} hostToCheck - Domain/host to check. Must be provided.
+ * @returns {boolean}
+ */
+export function checkDisallowedByCSP(responseHeaders, hostToCheck) {
+ if (!hostToCheck || typeof hostToCheck !== 'string') {
+ throw new Error('hostToCheck must be a non-empty string')
+ }
+ const policy = responseHeaders?.['content-security-policy']
+ if (!policy) return false
+
+ const directives = policy.split(';')
+
+ const allowed = directives.some((directive) => {
+ const d = directive.trim()
+ // Check for the provided host/domain
+ return d.includes(hostToCheck)
+ })
+
+ return !allowed
+}
diff --git a/tracker/installation_support/verifier-v2.js b/tracker/installation_support/verifier-v2.js
new file mode 100644
index 0000000000..4f8b1e09c0
--- /dev/null
+++ b/tracker/installation_support/verifier-v2.js
@@ -0,0 +1,220 @@
+/** @typedef {import('../test/support/types').VerifyV2Args} VerifyV2Args */
+/** @typedef {import('../test/support/types').VerifyV2Result} VerifyV2Result */
+import { checkCookieBanner } from './check-cookie-banner'
+import { checkDisallowedByCSP } from './check-disallowed-by-csp'
+
+/**
+ * Function that verifies if Plausible is installed correctly.
+ * @param {VerifyV2Args}
+ * @returns {Promise}
+ */
+
+async function verifyPlausibleInstallation({
+ timeoutMs,
+ responseHeaders,
+ debug,
+ cspHostToCheck
+}) {
+ function log(message) {
+ if (debug) console.log('[VERIFICATION v2]', message)
+ }
+
+ const disallowedByCsp = checkDisallowedByCSP(responseHeaders, cspHostToCheck)
+
+ const { stopRecording, getInterceptedFetch } = startRecordingEventFetchCalls()
+
+ const {
+ plausibleIsInitialized,
+ plausibleIsOnWindow,
+ plausibleVersion,
+ plausibleVariant,
+ testEvent,
+ error: testPlausibleFunctionError
+ } = await testPlausibleFunction({
+ timeoutMs
+ })
+
+ if (testPlausibleFunctionError) {
+ log(
+ `There was an error testing plausible function: ${testPlausibleFunctionError}`
+ )
+ }
+
+ stopRecording()
+
+ const interceptedTestEvent = getInterceptedFetch('verification-agent-test')
+
+ if (!interceptedTestEvent) {
+ log(`No test event request was among intercepted requests`)
+ }
+
+ const diagnostics = {
+ disallowedByCsp,
+ plausibleIsOnWindow,
+ plausibleIsInitialized,
+ plausibleVersion,
+ plausibleVariant,
+ testEvent: {
+ ...testEvent,
+ requestUrl: interceptedTestEvent?.request?.url,
+ normalizedBody: interceptedTestEvent?.request?.normalizedBody,
+ responseStatus: interceptedTestEvent?.response?.status,
+ error: interceptedTestEvent?.error
+ },
+ cookieBannerLikely: checkCookieBanner()
+ }
+
+ log({
+ diagnostics
+ })
+
+ return {
+ data: {
+ completed: true,
+ ...diagnostics
+ }
+ }
+}
+
+function getNormalizedPlausibleEventBody(fetchOptions) {
+ try {
+ const body = JSON.parse(fetchOptions.body ?? '{}')
+
+ let name = null
+ let domain = null
+ let version = null
+
+ if (
+ fetchOptions.method === 'POST' &&
+ (typeof body?.n === 'string' || typeof body?.name === 'string') &&
+ (typeof body?.d === 'string' || typeof body?.domain === 'string')
+ ) {
+ name = body?.n || body?.name
+ domain = body?.d || body?.domain
+ version = body?.v || body?.version
+ }
+ return name && domain ? { name, domain, version } : null
+ } catch (e) {}
+}
+
+function startRecordingEventFetchCalls() {
+ const interceptions = new Map()
+
+ const originalFetch = window.fetch
+ window.fetch = function (url, options = {}) {
+ let identifier = null
+
+ const normalizedEventBody = getNormalizedPlausibleEventBody(options)
+ if (normalizedEventBody) {
+ identifier = normalizedEventBody.name
+ interceptions.set(identifier, {
+ request: { url, normalizedBody: normalizedEventBody }
+ })
+ }
+
+ return originalFetch
+ .apply(this, arguments)
+ .then(async (response) => {
+ const eventRequest = interceptions.get(identifier)
+ if (eventRequest) {
+ const responseClone = response.clone()
+ const body = await responseClone.text()
+ eventRequest.response = { status: response.status, body }
+ }
+ return response
+ })
+ .catch((error) => {
+ const eventRequest = interceptions.get(identifier)
+ if (eventRequest) {
+ eventRequest.error = {
+ message: error?.message || 'Unknown error during fetch'
+ }
+ }
+ throw error
+ })
+ }
+ return {
+ getInterceptedFetch: (identifier) => interceptions.get(identifier),
+ stopRecording: () => {
+ window.fetch = originalFetch
+ }
+ }
+}
+
+function isPlausibleOnWindow() {
+ return !!window.plausible
+}
+
+function isPlausibleInitialized() {
+ return window.plausible?.l
+}
+
+function getPlausibleVersion() {
+ return window.plausible?.v
+}
+
+function getPlausibleVariant() {
+ return window.plausible?.s
+}
+
+async function testPlausibleFunction({ timeoutMs }) {
+ return new Promise(async (_resolve) => {
+ let plausibleIsOnWindow = isPlausibleOnWindow()
+ let plausibleIsInitialized = isPlausibleInitialized()
+ let plausibleVersion = getPlausibleVersion()
+ let plausibleVariant = getPlausibleVariant()
+ let testEvent = {}
+
+ let resolved = false
+
+ function resolve(additionalData) {
+ resolved = true
+ _resolve({
+ plausibleIsInitialized,
+ plausibleIsOnWindow,
+ plausibleVersion,
+ plausibleVariant,
+ testEvent,
+ ...additionalData
+ })
+ }
+
+ const timeout = setTimeout(() => {
+ resolve({
+ error: 'Test Plausible function timeout exceeded'
+ })
+ }, timeoutMs)
+
+ while (!plausibleIsOnWindow) {
+ if (isPlausibleOnWindow()) {
+ plausibleIsOnWindow = true
+ }
+ await delay(10)
+ }
+
+ while (!plausibleIsInitialized) {
+ if (isPlausibleInitialized()) {
+ plausibleIsInitialized = true
+ plausibleVersion = getPlausibleVersion()
+ plausibleVariant = getPlausibleVariant()
+ }
+ await delay(10)
+ }
+
+ window.plausible('verification-agent-test', {
+ callback: (testEventCallbackResult) => {
+ if (resolved) return
+ clearTimeout(timeout)
+ resolve({
+ testEvent: { callbackResult: testEventCallbackResult }
+ })
+ }
+ })
+ })
+}
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+window.verifyPlausibleInstallation = verifyPlausibleInstallation
diff --git a/tracker/test/installation_support/check-disallowed-by-csp.spec.js b/tracker/test/installation_support/check-disallowed-by-csp.spec.js
new file mode 100644
index 0000000000..963077d7f2
--- /dev/null
+++ b/tracker/test/installation_support/check-disallowed-by-csp.spec.js
@@ -0,0 +1,40 @@
+import { test, expect } from '@playwright/test'
+import { checkDisallowedByCSP } from '../../installation_support/check-disallowed-by-csp'
+
+const HOST_TO_CHECK = 'plausible.io'
+
+test.describe('checkDisallowedByCSP', () => {
+ test('returns false if no CSP header', () => {
+ expect(checkDisallowedByCSP({}, HOST_TO_CHECK)).toBe(false)
+ expect(checkDisallowedByCSP({foo: 'bar'}, HOST_TO_CHECK)).toBe(false)
+ })
+
+ test('returns false if CSP header is empty', () => {
+ expect(checkDisallowedByCSP({'content-security-policy': ''}, HOST_TO_CHECK)).toBe(false)
+ })
+
+ test('returns true if plausible.io is not allowed', () => {
+ const headers = {'content-security-policy': "default-src 'self' foo.local; example.com"}
+ expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true)
+ })
+
+ test('returns false if plausible.io is allowed', () => {
+ const headers = {'content-security-policy': "default-src 'self' plausible.io; example.com"}
+ expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false)
+ })
+
+ test('returns false if plausible.io subdomain is allowed', () => {
+ const headers = {'content-security-policy': "default-src 'self' staging.plausible.io; example.com"}
+ expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false)
+ })
+
+ test('returns false if plausible.io is allowed with https', () => {
+ const headers = {'content-security-policy': "default-src 'self' https://plausible.io; example.com"}
+ expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false)
+ })
+
+ test('returns true if plausible.io is not present in any directive', () => {
+ const headers = {'content-security-policy': "default-src 'self' foo.com; bar.com"}
+ expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true)
+ })
+})
\ No newline at end of file
diff --git a/tracker/test/installation_support/verifier-v1.spec.js b/tracker/test/installation_support/verifier-v1.spec.js
index b7811d914e..87a79521a0 100644
--- a/tracker/test/installation_support/verifier-v1.spec.js
+++ b/tracker/test/installation_support/verifier-v1.spec.js
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'
-import { verify } from '../support/installation-support-playwright-wrappers'
+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'
@@ -29,7 +29,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
@@ -72,7 +72,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
@@ -87,7 +87,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: ''
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.plausibleInstalled).toBe(false)
expect(result.data.callbackStatus).toBe(0)
@@ -104,7 +104,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
response: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(0)
@@ -121,7 +121,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
response: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: "example.com"})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: "example.com"})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
@@ -138,7 +138,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
response: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: "example.typo"})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: "example.typo"})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1)
@@ -178,7 +178,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.proxyLikely).toBe(false)
})
@@ -201,7 +201,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: "example.com"})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: "example.com"})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(2)
@@ -218,7 +218,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: 'right.com'})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: 'right.com'})
expect(result.data.dataDomainMismatch).toBe(true)
})
@@ -231,7 +231,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: 'right.com'})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: 'right.com'})
expect(result.data.dataDomainMismatch).toBe(false)
})
@@ -249,7 +249,7 @@ test.describe('v1 verifier (window.plausible)', () => {
scriptConfig: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(404)
@@ -263,7 +263,7 @@ test.describe('v1 verifier (window.plausible)', () => {
scriptConfig: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(0)
@@ -279,7 +279,7 @@ test.describe('v1 verifier (window.plausible)', () => {
scriptConfig: ``
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(-1)
@@ -300,7 +300,7 @@ test.describe('v1 verifier (WordPress detection)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.wordpressPlugin).toBe(true)
expect(result.data.wordpressLikely).toBe(true)
@@ -321,7 +321,7 @@ test.describe('v1 verifier (WordPress detection)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.wordpressPlugin).toBe(false)
expect(result.data.wordpressLikely).toBe(true)
@@ -353,7 +353,7 @@ test.describe('v1 verifier (GTM detection)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.gtmLikely).toBe(true)
})
@@ -392,7 +392,7 @@ test.describe('v1 verifier (cookieBanner detection)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.cookieBannerLikely).toBe(true)
})
@@ -417,7 +417,7 @@ test.describe('v1 verifier (manualScriptExtension detection)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.manualScriptExtension).toBe(true)
})
@@ -447,7 +447,7 @@ test.describe('v1 verifier (unknownAttributes detection)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.unknownAttributes).toBe(false)
})
@@ -466,7 +466,7 @@ test.describe('v1 verifier (unknownAttributes detection)', () => {
`
})
- const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(result.data.unknownAttributes).toBe(true)
})
@@ -484,7 +484,7 @@ test.describe('v1 verifier (logging)', () => {
scriptConfig: ``
})
- await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true})
+ 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')
@@ -501,7 +501,7 @@ test.describe('v1 verifier (logging)', () => {
scriptConfig: ``
})
- await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN})
+ await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(logs.length).toBe(0)
})
diff --git a/tracker/test/installation_support/verifier-v2.spec.ts b/tracker/test/installation_support/verifier-v2.spec.ts
new file mode 100644
index 0000000000..2e134e0868
--- /dev/null
+++ b/tracker/test/installation_support/verifier-v2.spec.ts
@@ -0,0 +1,449 @@
+import { test, expect } from '@playwright/test'
+import { executeVerifyV2 } 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'
+import { tracker_script_version as version } from '../support/test-utils'
+
+const CSP_HOST_TO_CHECK = 'plausible.io'
+
+test.describe('installed plausible web variant', () => {
+ test('using provided snippet', async ({ page }, { testId }) => {
+ await mockManyRequests({
+ page,
+ path: `https://plausible.io/api/event`,
+ awaitedRequestCount: 1,
+ fulfill: {
+ status: 202,
+ contentType: 'text/plain',
+ body: 'ok'
+ }
+ })
+
+ const { url } = await initializePageDynamically(page, {
+ testId,
+ scriptConfig: {
+ domain: 'example.com',
+ endpoint: `https://plausible.io/api/event`,
+ captureOnLocalhost: true
+ },
+ bodyContent: ''
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'web',
+ testEvent: {
+ callbackResult: { status: 202 },
+ requestUrl: 'https://plausible.io/api/event',
+ normalizedBody: {
+ domain: 'example.com',
+ name: 'verification-agent-test',
+ version
+ },
+ responseStatus: 202,
+ error: undefined
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+
+ test('using provided snippet and the events endpoint responds slower than the timeout', async ({
+ page
+ }, { testId }) => {
+ await mockManyRequests({
+ page,
+ path: `https://plausible.io/api/event`,
+ awaitedRequestCount: 1,
+ fulfill: {
+ status: 202,
+ contentType: 'text/plain',
+ body: 'ok'
+ },
+ responseDelay: 2000
+ })
+
+ const { url } = await initializePageDynamically(page, {
+ testId,
+ scriptConfig: {
+ domain: 'example.com',
+ endpoint: `https://plausible.io/api/event`,
+ captureOnLocalhost: true
+ },
+ bodyContent: ''
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'web',
+ testEvent: {
+ callbackResult: undefined,
+ requestUrl: 'https://plausible.io/api/event',
+ normalizedBody: {
+ domain: 'example.com',
+ name: 'verification-agent-test',
+ version
+ },
+ responseStatus: undefined,
+ error: undefined
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+
+ test('using provided snippet and the events endpoint responds with 400', async ({
+ page
+ }, { testId }) => {
+ await mockManyRequests({
+ page,
+ path: `https://plausible.io/api/event`,
+ awaitedRequestCount: 1,
+ fulfill: {
+ status: 400,
+ contentType: 'text/plain',
+ body: 'Bad Request'
+ }
+ })
+
+ const { url } = await initializePageDynamically(page, {
+ testId,
+ scriptConfig: {
+ domain: 'example.com',
+ endpoint: `https://plausible.io/api/event`,
+ captureOnLocalhost: true
+ },
+ bodyContent: ''
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'web',
+ testEvent: {
+ callbackResult: { status: 400 },
+ requestUrl: 'https://plausible.io/api/event',
+ normalizedBody: {
+ domain: 'example.com',
+ name: 'verification-agent-test',
+ version
+ },
+ responseStatus: 400,
+ error: undefined
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+
+ test('using provided snippet and captureOnLocalhost: false', async ({
+ page
+ }, { testId }) => {
+ const { url } = await initializePageDynamically(page, {
+ testId,
+ scriptConfig: {
+ domain: 'example.com/foobar',
+ endpoint: 'https://plausible.io/api/event',
+ captureOnLocalhost: false
+ },
+ bodyContent: ''
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'web',
+ testEvent: {
+ callbackResult: undefined,
+ requestUrl: undefined,
+ normalizedBody: undefined,
+ responseStatus: undefined,
+ error: undefined
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+})
+
+test.describe('installed plausible esm variant', () => {
+ test('using `,
+ bodyContent: ''
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'npm',
+ testEvent: {
+ callbackResult: { status: 202 },
+ requestUrl: 'https://plausible.io/api/event',
+ normalizedBody: {
+ domain: 'example.com',
+ name: 'verification-agent-test',
+ version
+ },
+ responseStatus: 202,
+ error: undefined
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+
+ test('using `,
+ bodyContent: ''
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'npm',
+ testEvent: {
+ callbackResult: { status: 202 },
+ requestUrl: '/events',
+ normalizedBody: {
+ domain: 'example.com',
+ name: 'verification-agent-test',
+ version
+ },
+ responseStatus: 202,
+ error: undefined
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+
+ test('using `,
+ bodyContent: ''
+ })
+
+ await mockManyRequests({
+ page,
+ path: `https://example.com/events`,
+ awaitedRequestCount: 1,
+ fulfill: {
+ status: 500,
+ contentType: 'text/plain',
+ body: 'Unknown error'
+ }
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'npm',
+ testEvent: {
+ callbackResult: { status: 500 },
+ requestUrl: 'https://example.com/events',
+ normalizedBody: {
+ domain: 'example.com',
+ name: 'verification-agent-test',
+ version
+ },
+ responseStatus: 500,
+ error: undefined
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+
+ test('using `,
+ bodyContent: ''
+ })
+
+ await page.goto(url)
+
+ const result = await executeVerifyV2(page, {
+ responseHeaders: {},
+ debug: true,
+ timeoutMs: 1000,
+ cspHostToCheck: CSP_HOST_TO_CHECK
+ })
+
+ expect(result).toEqual({
+ data: {
+ completed: true,
+ plausibleIsInitialized: true,
+ plausibleIsOnWindow: true,
+ disallowedByCsp: false,
+ plausibleVersion: version,
+ plausibleVariant: 'npm',
+ testEvent: {
+ callbackResult: {
+ error: expect.objectContaining({ message: 'Failed to fetch' })
+ },
+ requestUrl: 'invalid:/plausible.io/api/event',
+ normalizedBody: {
+ domain: 'example.com/foobar',
+ name: 'verification-agent-test',
+ version
+ },
+ responseStatus: undefined,
+ error: { message: 'Failed to fetch' }
+ },
+ cookieBannerLikely: false
+ }
+ })
+ })
+})
diff --git a/tracker/test/support/installation-support-playwright-wrappers.js b/tracker/test/support/installation-support-playwright-wrappers.js
deleted file mode 100644
index 02a3094edc..0000000000
--- a/tracker/test/support/installation-support-playwright-wrappers.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { compileFile } from '../../compiler/index.js'
-import variantsFile from '../../compiler/variants.json' with { type: 'json' }
-
-const VERIFIER_V1_JS_VARIANT = variantsFile.manualVariants.find(variant => variant.name === 'verifier-v1.js')
-const DETECTOR_JS_VARIANT = variantsFile.manualVariants.find(variant => variant.name === 'detector.js')
-
-export async function verify(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}) => {
- return await window.verifyPlausibleInstallation(expectedDataDomain, debug)
- }, {expectedDataDomain, debug})
-}
-
-export async function detect(page, context) {
- const {url, detectV1} = context
- const debug = context.debug ? true : false
-
- const detectorCode = await compileFile(DETECTOR_JS_VARIANT, { returnCode: true })
-
- await page.goto(url)
- await page.evaluate(detectorCode)
-
- return await page.evaluate(async ({detectV1, debug}) => {
- return await window.scanPageBeforePlausibleInstallation(detectV1, debug)
- }, {detectV1, debug})
-}
diff --git a/tracker/test/support/installation-support-playwright-wrappers.ts b/tracker/test/support/installation-support-playwright-wrappers.ts
new file mode 100644
index 0000000000..6c2dd5b5e0
--- /dev/null
+++ b/tracker/test/support/installation-support-playwright-wrappers.ts
@@ -0,0 +1,92 @@
+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'
+
+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 DETECTOR_JS_VARIANT = variantsFile.manualVariants.find(
+ (variant) => variant.name === 'detector.js'
+)
+
+export async function executeVerifyV2(
+ page: Page,
+ { debug, responseHeaders, timeoutMs, cspHostToCheck }: VerifyV2Args
+): Promise {
+ const verifierCode = (await compileFile(VERIFIER_V2_JS_VARIANT, {
+ returnCode: true
+ })) as string
+
+ try {
+ await page.evaluate(verifierCode)
+
+ return await page.evaluate(
+ async ({ responseHeaders, debug, timeoutMs, cspHostToCheck }) => {
+ return await (window as any).verifyPlausibleInstallation({
+ responseHeaders,
+ debug,
+ timeoutMs,
+ cspHostToCheck
+ })
+ },
+ { responseHeaders, debug, timeoutMs, cspHostToCheck }
+ )
+ } catch (error) {
+ return {
+ data: {
+ completed: false,
+ error: {
+ message: error?.message ?? JSON.stringify(error)
+ }
+ }
+ }
+ }
+}
+
+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 }) => {
+ return await (window as any).verifyPlausibleInstallation(
+ expectedDataDomain,
+ debug
+ )
+ },
+ { expectedDataDomain, debug }
+ )
+}
+
+export async function detect(page, context) {
+ const { url, detectV1 } = context
+ const debug = context.debug ? true : false
+
+ const detectorCode = await compileFile(DETECTOR_JS_VARIANT, {
+ returnCode: true
+ })
+
+ await page.goto(url)
+ await page.evaluate(detectorCode)
+
+ return await page.evaluate(
+ async ({ detectV1, debug }) => {
+ return await (window as any).scanPageBeforePlausibleInstallation(
+ detectV1,
+ debug
+ )
+ },
+ { detectV1, debug }
+ )
+}
diff --git a/tracker/test/support/types.ts b/tracker/test/support/types.ts
index 800af7cf74..ba20c215a4 100644
--- a/tracker/test/support/types.ts
+++ b/tracker/test/support/types.ts
@@ -11,3 +11,52 @@ export type ScriptConfig = {
domain: string
endpoint: string
} & Partial
+
+export type VerifyV2Args = {
+ debug: boolean
+ responseHeaders: Record
+ timeoutMs: number
+ cspHostToCheck: string
+}
+
+export type VerifyV2Result = {
+ data:
+ | {
+ completed: true
+ plausibleIsOnWindow: boolean
+ plausibleIsInitialized: boolean
+ plausibleVersion: number
+ plausibleVariant?: string
+ disallowedByCsp: boolean
+ cookieBannerLikely: boolean
+ testEvent: {
+ /**
+ * window.plausible (track) callback
+ */
+ callbackResult?: any
+ /**
+ * intercepted fetch response status
+ */
+ responseStatus?: number
+ /**
+ * error caught during intercepted fetch
+ */
+ error?: {
+ message: string
+ }
+ /**
+ * intercepted fetch request url
+ */
+ requestUrl?: string
+ /**
+ * intercepted fetch request body normalized
+ */
+ normalizedBody?: {
+ domain: string
+ name: string
+ version?: number
+ }
+ }
+ }
+ | { completed: false; error: { message: string } }
+}