analytics/tracker/installation_support/verifier.js

315 lines
8.4 KiB
JavaScript

/** @typedef {import('../test/support/types').VerifierArgs} VerifierArgs */
/** @typedef {import('../test/support/types').VerifierResult} VerifierResult */
import { initializeCookieConsentEngine } from './autoconsent-to-cookies'
import { checkDisallowedByCSP } from './check-disallowed-by-csp'
/**
* Function that verifies if Plausible is installed correctly.
* @param {VerifierArgs}
* @returns {Promise<VerifierResult>}
*/
const DEFAULT_TRACKER_SCRIPT_SELECTOR = 'script[src^="https://plausible.io/js"]'
async function verifyPlausibleInstallation(options) {
const {
timeoutMs,
responseHeaders,
debug,
cspHostToCheck,
trackerScriptSelector
} = {
trackerScriptSelector: DEFAULT_TRACKER_SCRIPT_SELECTOR,
...options
}
function log(message) {
if (debug) console.log('[VERIFICATION]', message)
}
const disallowedByCsp = checkDisallowedByCSP(responseHeaders, cspHostToCheck)
forceIgnoreWebdriverCondition()
const { stopRecording, getInterceptedFetch } = startRecordingEventFetchCalls()
const {
plausibleIsInitialized,
plausibleIsOnWindow,
plausibleVersion,
plausibleVariant,
testEvent,
cookiesConsentResult,
error: testPlausibleFunctionError
} = await testPlausibleFunction({
timeoutMs,
debug
})
const trackerIsInHtml = isInHtml(trackerScriptSelector)
if (testPlausibleFunctionError) {
log(
`There was an error testing plausible function: ${testPlausibleFunctionError}`
)
}
stopRecording()
let interceptedTestEvent = getInterceptedFetch('verification-agent-test')
if (!interceptedTestEvent) {
log(`No test event request was among intercepted requests`)
}
// this can be removed once most sites have migrated to v2 and WP plugin is migrated to v2
if (
!interceptedTestEvent &&
[200, 202].includes(testEvent.callbackResult?.status)
) {
log(
`The callback result indicates a successful request, assuming legacy .compat installation that uses XMLHttpRequest`
)
const firstLegacySnippet = document.querySelector(
'script[data-domain][src]'
)
if (firstLegacySnippet) {
// legacy installations may list multiple domains in a comma-separated list
const domainString = firstLegacySnippet.getAttribute('data-domain')
const firstDomain = domainString && domainString.split(',').shift()
interceptedTestEvent = {
request: {
normalizedBody: {
__legacyCompatInstallation: true,
domain: firstDomain
}
},
response: { status: testEvent.callbackResult.status }
}
}
}
const diagnostics = {
disallowedByCsp,
trackerIsInHtml,
plausibleIsOnWindow,
plausibleIsInitialized,
plausibleVersion,
plausibleVariant,
testEvent: {
...testEvent, // callbackResult
testPlausibleFunctionError,
requestUrl: interceptedTestEvent?.request?.url,
normalizedBody: interceptedTestEvent?.request?.normalizedBody,
responseStatus: interceptedTestEvent?.response?.status,
error: interceptedTestEvent?.error
},
cookiesConsentResult
}
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 (_error) {
// ignore error
}
}
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 isInHtml(selector) {
return document.querySelector(selector) !== null
}
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, debug }) {
return new Promise((_resolve) => {
let plausibleIsOnWindow = isPlausibleOnWindow()
let plausibleIsInitialized = isPlausibleInitialized()
let plausibleVersion = getPlausibleVersion()
let plausibleVariant = getPlausibleVariant()
let testEvent = {}
let cookiesConsentResult = {
handled: null,
engineLifecycle: 'not-started'
}
let timeout = null
let plausibleOnWindowPollInterval = null
let plausibleInitializedPollInterval = null
let testEventPollInterval = null
let resolved = false
const resolve = (overrides) => {
clearTimeout(timeout)
clearInterval(plausibleOnWindowPollInterval)
clearInterval(plausibleInitializedPollInterval)
clearInterval(testEventPollInterval)
if (resolved) {
return
}
resolved = true
_resolve({
plausibleIsOnWindow,
plausibleIsInitialized,
plausibleVersion,
plausibleVariant,
testEvent,
cookiesConsentResult,
...overrides
})
}
timeout = setTimeout(() => {
resolve({
error: 'Test Plausible function timeout exceeded'
})
}, timeoutMs)
plausibleOnWindowPollInterval = setInterval(
() =>
plausibleIsOnWindow
? clearInterval(plausibleOnWindowPollInterval)
: (plausibleIsOnWindow = isPlausibleOnWindow()),
10
)
plausibleInitializedPollInterval = setInterval(() => {
if (plausibleIsInitialized) {
plausibleVersion = getPlausibleVersion()
plausibleVariant = getPlausibleVariant()
clearInterval(plausibleInitializedPollInterval)
} else {
plausibleIsInitialized = isPlausibleInitialized()
}
}, 10)
testEventPollInterval = setInterval(() => {
if (plausibleIsOnWindow && plausibleIsInitialized) {
window.plausible('verification-agent-test', {
callback: (testEventCallbackResult) => {
resolve({
testEvent: {
callbackResult: testEventCallbackResult ?? 'undefined or null'
}
})
}
})
clearInterval(testEventPollInterval)
}
}, 10)
cookiesConsentResult = initializeCookieConsentEngine({
debug,
onConsentDone: (cmp) => {
if (resolved) return
cookiesConsentResult = { handled: true, cmp }
},
onConsentError: (err) => {
if (resolved) return
cookiesConsentResult = { handled: false, error: err }
},
onLifecycleUpdate: (lifecycle) => {
if (resolved) return
// skips messages that might override consent success or error
if (cookiesConsentResult.handled !== null) return
if (lifecycle === 'done') {
cookiesConsentResult = { handled: true }
} else {
cookiesConsentResult.engineLifecycle = lifecycle
}
}
})
})
}
function forceIgnoreWebdriverCondition() {
window.__plausible = true
}
window.verifyPlausibleInstallation = verifyPlausibleInstallation