Script v2: Allow verifying that tracker installed correctly (1st iteration) (#5572)

* Sketch out verification for v2 installs

* WIP

* Fix naming

* Implement CSP check, refactor what test event output looks like

* Update error matcher

* Better typedefs

* Unify error format

* WIP diagnostics

* Delete superfluos doc

* Remove prettierrc

* Fix type

* Remove superfluous error file

* Remove foobar.md

* Fix format and variable names, ensure compliance for errors

* Add cache bust check, fix success diagnostics

* Fix v2 verifier spec and add moduledocs

* Make test not dependent on tracker script version

* Make verifier less CPU intensive

* Change the signature of checkDisallowedByCSP

* Fix unused service_errror matcher

* Refactor data_domain to expected_domain

* Ignore request URL

* Add case for for succeeding after cache bust

* Fix infinite recursion

* Relax CSP error interpretation

* Fix sentry message, ignore plausible.s
This commit is contained in:
Artur Pata 2025-07-31 11:10:44 +03:00 committed by GitHub
parent cd875eee96
commit bb17a17e5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1309 additions and 75 deletions

View File

@ -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

View File

@ -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

View File

@ -25,14 +25,7 @@ defmodule Plausible.InstallationSupport.LegacyVerification.Diagnostics do
@type t :: %__MODULE__{} @type t :: %__MODULE__{}
defmodule Result do alias Plausible.InstallationSupport.Result
@moduledoc """
Diagnostics interpretation result.
"""
defstruct ok?: false, errors: [], recommendations: []
@type t :: %__MODULE__{}
end
@spec interpret(t(), String.t()) :: Result.t() @spec interpret(t(), String.t()) :: Result.t()
def interpret( def interpret(
%__MODULE__{ %__MODULE__{

View File

@ -0,0 +1,7 @@
defmodule Plausible.InstallationSupport.Result do
@moduledoc """
Diagnostics interpretation result.
"""
defstruct ok?: false, errors: [], recommendations: []
@type t :: %__MODULE__{}
end

View File

@ -12,7 +12,9 @@ defmodule Plausible.InstallationSupport.State do
assigns: %{}, assigns: %{},
diagnostics: %{} 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__{ @type t :: %__MODULE__{
url: String.t() | nil, url: String.t() | nil,

View File

@ -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

View File

@ -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

View File

@ -7,7 +7,7 @@ defmodule PlausibleWeb.Live.Components.Verification do
use Plausible use Plausible
alias PlausibleWeb.Router.Helpers, as: Routes alias PlausibleWeb.Router.Helpers, as: Routes
alias Plausible.InstallationSupport.{State, LegacyVerification} alias Plausible.InstallationSupport.{State, Result}
import PlausibleWeb.Components.Generic import PlausibleWeb.Components.Generic
@ -21,7 +21,7 @@ defmodule PlausibleWeb.Live.Components.Verification do
attr(:finished?, :boolean, default: false) attr(:finished?, :boolean, default: false)
attr(:success?, :boolean, default: false) attr(:success?, :boolean, default: false)
attr(:verification_state, State, default: nil) attr(:verification_state, State, default: nil)
attr(:interpretation, LegacyVerification.Diagnostics.Result, default: nil) attr(:interpretation, Result, default: nil)
attr(:attempts, :integer, default: 0) attr(:attempts, :integer, default: 0)
attr(:flow, :string, default: "") attr(:flow, :string, default: "")
attr(:installation_type, :string, default: nil) attr(:installation_type, :string, default: nil)
@ -146,7 +146,8 @@ defmodule PlausibleWeb.Live.Components.Verification do
<.focus_list> <.focus_list>
<:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}> <:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}>
<span class="text-sm"> <span class="text-sm">
{Phoenix.Naming.humanize(diag)}: <span class="font-mono">{value}</span> {Phoenix.Naming.humanize(diag)}:
<span class="font-mono">{to_string_value(value)}</span>
</span> </span>
</:item> </:item>
</.focus_list> </.focus_list>
@ -157,4 +158,7 @@ defmodule PlausibleWeb.Live.Components.Verification do
</div> </div>
""" """
end end
defp to_string_value(value) when is_binary(value), do: value
defp to_string_value(value), do: inspect(value)
end end

View File

@ -7,7 +7,7 @@ defmodule PlausibleWeb.Live.Verification do
use Plausible use Plausible
use PlausibleWeb, :live_view use PlausibleWeb, :live_view
alias Plausible.InstallationSupport.{State, LegacyVerification} alias Plausible.InstallationSupport.{State, LegacyVerification, Verification}
@component PlausibleWeb.Live.Components.Verification @component PlausibleWeb.Live.Components.Verification
@slowdown_for_frequent_checking :timer.seconds(5) @slowdown_for_frequent_checking :timer.seconds(5)
@ -131,12 +131,24 @@ defmodule PlausibleWeb.Live.Verification do
{:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking) {:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking)
end end
url_to_verify = "https://#{socket.assigns.domain}"
domain = socket.assigns.domain
installation_type = socket.assigns.installation_type
{:ok, pid} = {:ok, pid} =
LegacyVerification.Checks.run( if(FunWithFlags.enabled?(:scriptv2, for: socket.assigns.site),
"https://#{socket.assigns.domain}", do:
socket.assigns.domain, Verification.Checks.run(url_to_verify, domain, installation_type,
report_to: report_to, report_to: report_to,
slowdown: socket.assigns.slowdown 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)} {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)}
@ -152,7 +164,11 @@ defmodule PlausibleWeb.Live.Verification do
end end
def handle_info({:all_checks_done, %State{} = state}, socket) do 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 if not socket.assigns.has_pageviews? do
schedule_pageviews_check(socket) schedule_pageviews_check(socket)

View File

@ -42,6 +42,12 @@
"entry_point": "installation_support/verifier-v1.js", "entry_point": "installation_support/verifier-v1.js",
"output_path": "priv/tracker/installation_support/verifier-v1.js", "output_path": "priv/tracker/installation_support/verifier-v1.js",
"globals": {} "globals": {}
},
{
"name": "verifier-v2.js",
"entry_point": "installation_support/verifier-v2.js",
"output_path": "priv/tracker/installation_support/verifier-v2.js",
"globals": {}
} }
], ],
"legacyVariants": [ "legacyVariants": [

View File

@ -0,0 +1,23 @@
/**
* Checks if the CSP policy disallows the given host/domain.
* @param {Record<string, string>} 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
}

View File

@ -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<VerifyV2Result>}
*/
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

View File

@ -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)
})
})

View File

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test' 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 { delay } from '../support/test-utils'
import { initializePageDynamically } from '../support/initialize-page-dynamically' import { initializePageDynamically } from '../support/initialize-page-dynamically'
import { compileFile } from '../../compiler' import { compileFile } from '../../compiler'
@ -29,7 +29,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>`
}) })
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.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1) 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.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1) expect(result.data.snippetsFoundInHead).toBe(1)
@ -87,7 +87,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: '' 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.plausibleInstalled).toBe(false)
expect(result.data.callbackStatus).toBe(0) expect(result.data.callbackStatus).toBe(0)
@ -104,7 +104,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
response: `<body><script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script></body>` response: `<body><script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script></body>`
}) })
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.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(0) expect(result.data.snippetsFoundInHead).toBe(0)
@ -121,7 +121,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
response: `<head><script defer data-domain="example.org,example.com,example.net" src="/tracker/js/plausible.local.js"></script></head>` response: `<head><script defer data-domain="example.org,example.com,example.net" src="/tracker/js/plausible.local.js"></script></head>`
}) })
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.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1) expect(result.data.snippetsFoundInHead).toBe(1)
@ -138,7 +138,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
response: `<head><script defer data-domain="example.org,example.com,example.net" src="/tracker/js/plausible.local.js"></script></head>` response: `<head><script defer data-domain="example.org,example.com,example.net" src="/tracker/js/plausible.local.js"></script></head>`
}) })
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.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(1) 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) 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.plausibleInstalled).toBe(true)
expect(result.data.snippetsFoundInHead).toBe(2) expect(result.data.snippetsFoundInHead).toBe(2)
@ -218,7 +218,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: `<script defer data-domain="wrong.com" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="wrong.com" src="/tracker/js/plausible.local.js"></script>`
}) })
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) expect(result.data.dataDomainMismatch).toBe(true)
}) })
@ -231,7 +231,7 @@ test.describe('v1 verifier (basic diagnostics)', () => {
scriptConfig: `<script defer data-domain="www.right.com" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="www.right.com" src="/tracker/js/plausible.local.js"></script>`
}) })
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) expect(result.data.dataDomainMismatch).toBe(false)
}) })
@ -249,7 +249,7 @@ test.describe('v1 verifier (window.plausible)', () => {
scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>`
}) })
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.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(404) expect(result.data.callbackStatus).toBe(404)
@ -263,7 +263,7 @@ test.describe('v1 verifier (window.plausible)', () => {
scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>`
}) })
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.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(0) expect(result.data.callbackStatus).toBe(0)
@ -279,7 +279,7 @@ test.describe('v1 verifier (window.plausible)', () => {
scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>`
}) })
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.plausibleInstalled).toBe(true)
expect(result.data.callbackStatus).toBe(-1) 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.wordpressPlugin).toBe(true)
expect(result.data.wordpressLikely).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.wordpressPlugin).toBe(false)
expect(result.data.wordpressLikely).toBe(true) 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) 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) 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) 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) 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) expect(result.data.unknownAttributes).toBe(true)
}) })
@ -484,7 +484,7 @@ test.describe('v1 verifier (logging)', () => {
scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>`
}) })
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('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') 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: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>` scriptConfig: `<script defer data-domain="${SOME_DOMAIN}" src="/tracker/js/plausible.local.js"></script>`
}) })
await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN})
expect(logs.length).toBe(0) expect(logs.length).toBe(0)
}) })

View File

@ -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 <script type="module"> tag', 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: `<script type="module">import { init, track } from '/tracker/js/npm_package/plausible.js'; window.init = init; window.track = track; init(${JSON.stringify(
{
domain: 'example.com',
endpoint: `https://plausible.io/api/event`,
captureOnLocalhost: true
}
)})</script>`,
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 <script type="module"> tag and endpoint: "/events"', async ({
page
}, { testId }) => {
await mockManyRequests({
page,
path: `${LOCAL_SERVER_ADDR}/events`,
awaitedRequestCount: 1,
fulfill: {
status: 202,
contentType: 'text/plain',
body: 'ok'
}
})
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: `<script type="module">import { init, track } from '/tracker/js/npm_package/plausible.js'; window.init = init; window.track = track; init(${JSON.stringify(
{
domain: 'example.com',
endpoint: `/events`,
captureOnLocalhost: true
}
)})</script>`,
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 <script type="module"> tag and endpoint: "https://example.com/events"', async ({
page
}, { testId }) => {
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: `<script type="module">import { init, track } from '/tracker/js/npm_package/plausible.js'; window.init = init; window.track = track; init(${JSON.stringify(
{
domain: 'example.com',
endpoint: `https://example.com/events`,
captureOnLocalhost: true
}
)})</script>`,
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 <script type="module"> tag and invalid endpoint: "invalid:/plausible.io/api/event"', async ({
page
}, { testId }) => {
const { url } = await initializePageDynamically(page, {
testId,
scriptConfig: `<script type="module">import { init, track } from '/tracker/js/npm_package/plausible.js'; window.init = init; window.track = track; init(${JSON.stringify(
{
domain: 'example.com/foobar',
endpoint: 'invalid:/plausible.io/api/event',
captureOnLocalhost: true
}
)})</script>`,
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
}
})
})
})

View File

@ -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})
}

View File

@ -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<VerifyV2Result> {
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 }
)
}

View File

@ -11,3 +11,52 @@ export type ScriptConfig = {
domain: string domain: string
endpoint: string endpoint: string
} & Partial<Options> } & Partial<Options>
export type VerifyV2Args = {
debug: boolean
responseHeaders: Record<string, string>
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 } }
}