ScriptV2: NPM library codebase (#5459)

* Compile NPM modules into commonjs and esm modules

Note that since the module uses only vanilla es5 outside the export,
it's safe to just append to the minified output

* package.json, Typescript definition, generation for commonjs

* Scrap commonjs for the moment

Running into too big rabbit holes trying to get it off the ground

* Shared tests for npm and web

* Better way to identify npm module in fixture

* Simplify code

* Add NPM library specific tests

* Fix a flaky test

* Standardize a callsite

* npm_package standardize on

* Make legacy variant code more explicit

* Wrap code pre and post

* analyze-sizes.js should work with npm_package

* Port test changes from PR #5432

* Update outbound link feature test

* Await callInit

* Smaller npm module - mangle variables

* Clean up wrapping code

* chore: Bump tracker_script_version to 15

* Add debug messaging to expect

* Remove dead code

* Refactor, account for initialization delays in tests

* Remove needless features from compiler

As the compiler currently manages to compile everything in ~3 seconds,
the `features`/`compileIds` feature serves no more point. Scrapping it

* Re-inline readOutput

* Update tracker/test/plausible-npm.spec.js

Co-authored-by: Artur Pata <artur.pata@gmail.com>

* Remove redundant comment

---------

Co-authored-by: Artur Pata <artur.pata@gmail.com>
This commit is contained in:
Karl-Aksel Puulmann 2025-06-04 13:47:44 +03:00 committed by GitHub
parent d242e4ecdd
commit 595e71a399
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2671 additions and 8550 deletions

5
.gitignore vendored
View File

@ -47,6 +47,11 @@ npm-debug.log
# Temporary file used by analyze-sizes.js
/tracker/compiler/.analyze-sizes.json
# Tracker npm module files that are generated by the compiler for the NPM package
/tracker/npm_package/plausible.js*
/tracker/npm_package/plausible.cjs*
/tracker/npm_package/plausible.d.cts
# test coverage directory
/assets/coverage

View File

@ -2,11 +2,8 @@ import { parseArgs } from 'node:util'
import { compileAll, compileWebSnippet } from './compiler/index.js'
import chokidar from 'chokidar'
const { values, positionals } = parseArgs({
const { values } = parseArgs({
options: {
'target': {
type: 'string',
},
'watch': {
type: 'boolean',
short: 'w'
@ -21,14 +18,12 @@ const { values, positionals } = parseArgs({
'web-snippet': {
type: 'boolean',
}
},
allowPositionals: true
}
})
if (values.help) {
console.log('Usage: node compile.js [...compile-ids] [flags]')
console.log('Usage: node compile.js [flags]')
console.log('Options:')
console.log(' --target hash,outbound-links,exclusions Only compile variants that contain all specified compile-ids')
console.log(' --watch, -w Watch src/ directory for changes and recompile')
console.log(' --suffix, -s Suffix to add to the output file name. Used for testing script size changes')
console.log(' --help Show this help message')
@ -36,34 +31,21 @@ if (values.help) {
process.exit(0);
}
function parse(value) {
if (value == null) {
return null
}
return value
.split(/[.,]/)
.filter(feature => !['js', 'plausible'].includes(feature))
.sort()
}
const compileOptions = {
targets: parse(values.target),
only: positionals && positionals.length > 0 ? positionals.map(parse) : null,
suffix: values.suffix
}
if (values['web-snippet']) {
console.log(compileWebSnippet())
process.exit(0)
}
const compileOptions = {
suffix: values.suffix
}
await compileAll(compileOptions)
if (values.watch) {
console.log('Watching src/ directory for changes...')
chokidar.watch('./src').on('change', async (event, path) => {
chokidar.watch('./src').on('change', async (_event, path) => {
if (path) {
console.log(`\nFile changed: ${path}`)
console.log('Recompiling...')

View File

@ -44,6 +44,7 @@ const { values } = parseArgs({
const { currentSuffix, baselineSuffix } = values
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TRACKER_FILES_DIR = path.join(__dirname, "../../priv/tracker/js/")
const NPM_PACKAGE_FILES_DIR = path.join(__dirname, "../npm_package")
const HEADER = ['', 'Brotli', 'Gzip', 'Uncompressed']
@ -229,21 +230,34 @@ function addSign(value) {
}
function readPlausibleScriptSizes() {
const files = fs.readdirSync(TRACKER_FILES_DIR).filter((filename) =>
!['.gitkeep', 'p.js'].includes(filename) && (filename.includes(currentSuffix) || filename.includes(baselineSuffix))
)
const trackerFileSizes = fs.readdirSync(TRACKER_FILES_DIR)
.filter(isRelevantFile)
.map((filename) => readFileSize(filename, TRACKER_FILES_DIR))
return files.map((filename) => {
const filePath = path.join(TRACKER_FILES_DIR, filename)
const [_, variant, suffix] = /(.*)[.]js(.*)/.exec(filename)
return {
variant: `${variant}.js`,
suffix,
uncompressed: fs.statSync(filePath).size,
gzip: execSync(`gzip -c -9 "${filePath}"`).length,
brotli: execSync(`brotli -c -q 11 "${filePath}"`).length
}
})
const npmPackageFileSizes = fs.readdirSync(NPM_PACKAGE_FILES_DIR)
.filter(isRelevantFile)
.map((filename) => readFileSize(filename, NPM_PACKAGE_FILES_DIR))
return trackerFileSizes.concat(npmPackageFileSizes)
}
function readFileSize(filename, basepath) {
const filePath = path.join(basepath, filename)
const [_, variant, suffix] = /(.*)[.]js(.*)/.exec(filename)
return {
variant: (basepath === TRACKER_FILES_DIR ? `${variant}.js` : 'npm_package/plausible.js'),
suffix,
uncompressed: fs.statSync(filePath).size,
gzip: execSync(`gzip -c -9 "${filePath}"`).length,
brotli: execSync(`brotli -c -q 11 "${filePath}"`).length
}
}
function isRelevantFile(filename) {
return !['.gitkeep', 'p.js'].includes(filename) &&
filename.includes('.js') &&
(filename.includes(currentSuffix) || filename.includes(baselineSuffix))
}
function clickhouseLocal(sql, inputLines = null) {

View File

@ -14,8 +14,10 @@ let legacyVariants = [...g.clone.powerSet(LEGACY_VARIANT_NAMES)]
.map(a => a.sort())
.map((variant) => ({
name: variant.length > 0 ? `plausible.${variant.join('.')}.js` : 'plausible.js',
compileIds: variant,
globals: Object.fromEntries(variant.map(id => [idToGlobal(id), true]))
globals: {
...Object.fromEntries(variant.map(id => [idToGlobal(id), true])),
COMPILE_PLAUSIBLE_LEGACY_VARIANT: true
}
}))
const variantsFile = path.join(__dirname, 'variants.json')

View File

@ -1,4 +1,4 @@
import { minifySync as swcMinify } from '@swc/core'
import { minifySync } from '@swc/core'
import { rollup } from 'rollup'
import fs from 'fs'
import path from 'path'
@ -12,6 +12,10 @@ import { spawn, Worker, Pool } from "threads"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DEFAULT_GLOBALS = {
COMPILE_PLAUSIBLE_WEB: false,
COMPILE_PLAUSIBLE_NPM: false,
COMPILE_PLAUSIBLE_LEGACY_VARIANT: false,
COMPILE_CONFIG: false,
COMPILE_HASH: false,
COMPILE_OUTBOUND_LINKS: false,
COMPILE_COMPAT: false,
@ -24,26 +28,26 @@ const DEFAULT_GLOBALS = {
COMPILE_REVENUE: false,
COMPILE_EXCLUSIONS: false,
COMPILE_TRACKER_SCRIPT_VERSION: packageJson.tracker_script_version,
COMPILE_CONFIG: false
}
const ALL_VARIANTS = variantsFile.legacyVariants.concat(variantsFile.manualVariants)
export async function compileAll(options = {}) {
if (process.env.NODE_ENV === 'dev' && canSkipCompile()) {
console.info('COMPILATION SKIPPED: No changes detected in tracker dependencies')
return
}
const variants = getVariantsToCompile(options)
const bundledCode = await bundleCode()
const startTime = Date.now();
console.log(`Starting compilation of ${variants.length} variants...`)
console.log(`Starting compilation of ${ALL_VARIANTS.length} variants...`)
const bar = new progress.SingleBar({ clearOnComplete: true }, progress.Presets.shades_classic)
bar.start(variants.length, 0)
bar.start(ALL_VARIANTS.length, 0)
const workerPool = Pool(() => spawn(new Worker('./worker-thread.js')))
variants.forEach(variant => {
ALL_VARIANTS.forEach(variant => {
workerPool.queue(async (worker) => {
await worker.compileFile(variant, { ...options, bundledCode })
bar.increment()
@ -54,19 +58,27 @@ export async function compileAll(options = {}) {
await workerPool.terminate()
bar.stop()
console.log(`Completed compilation of ${variants.length} variants in ${((Date.now() - startTime) / 1000).toFixed(2)}s`);
console.log(`Completed compilation of ${ALL_VARIANTS.length} variants in ${((Date.now() - startTime) / 1000).toFixed(2)}s`);
}
export async function compileFile(variant, options) {
const wrappedCode = wrapInstantlyEvaluatingFunction(options.bundledCode || await bundleCode())
const globals = { ...DEFAULT_GLOBALS, ...variant.globals }
let code = options.bundledCode || await bundleCode()
const code = minify(wrappedCode, globals)
if (!variant.npm_package) {
code = wrapInstantlyEvaluatingFunction(code)
}
code = minify(code, globals, variant)
if (variant.npm_package) {
code = addExports(code)
}
if (options.returnCode) {
return code
} else {
fs.writeFileSync(relPath(`../../priv/tracker/js/${variant.name}${options.suffix || ""}`), code)
fs.writeFileSync(outputPath(variant, options), code)
}
}
@ -74,6 +86,11 @@ function wrapInstantlyEvaluatingFunction(baseCode) {
return `(function(){${baseCode}})()`
}
// Works around minification limitation of swc not allowing exports
function addExports(code) {
return `${code}\nexport { init, track }`
}
export function compileWebSnippet() {
const code = fs.readFileSync(relPath('../src/web-snippet.js')).toString()
return `
@ -84,41 +101,39 @@ export function compileWebSnippet() {
`
}
function getVariantsToCompile(options) {
let targetVariants = variantsFile.legacyVariants.concat(variantsFile.manualVariants)
if (options.targets !== null) {
targetVariants = targetVariants.filter(variant =>
options.targets.every(target => variant.compileIds.includes(target))
)
}
if (options.only !== null) {
targetVariants = targetVariants.filter(variant =>
options.only.some(targetCompileIds => equalLists(variant.compileIds, targetCompileIds))
)
}
return targetVariants
}
async function bundleCode() {
async function bundleCode(format = 'esm') {
const bundle = await rollup({
input: 'src/plausible.js',
})
const { output } = await bundle.generate({
format: 'esm',
})
const { output } = await bundle.generate({ format })
return output[0].code
}
function minify(baseCode, globals) {
const result = swcMinify(baseCode, {
function outputPath(variant, options) {
if (variant.npm_package) {
return relPath(`../${variant.name}${options.suffix || ""}`)
} else {
return relPath(`../../priv/tracker/js/${variant.name}${options.suffix || ""}`)
}
}
function minify(code, globals, variant = {}) {
const minifyOptions = {
compress: {
global_defs: globals,
passes: 4
}
})
},
mangle: {}
}
if (variant.npm_package) {
minifyOptions.mangle.reserved = ['init', 'track']
minifyOptions.mangle.toplevel = true
}
const result = minifySync(code, minifyOptions)
if (result.code) {
return result.code
@ -127,18 +142,6 @@ function minify(baseCode, globals) {
}
}
function equalLists(a, b) {
if (a.length != b.length) {
return false
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false
}
}
return true
}
function relPath(segment) {
return path.join(__dirname, segment)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
{
"name": "macobo-test-tracker",
"version": "0.0.1",
"description": "Plausible Analytics official frontend tracking library",
"scripts": {
"test": "echo \"Error: Testing done in the tracker folder\" && exit 1"
},
"repository": "https://github.com/plausible/plausible",
"keywords": [
"web-analytics",
"website-analytics",
"analytics"
],
"author": "Plausible Analytics",
"license": "AGPLv3",
"homepage": "https://plausible.io",
"type": "module",
"module": "./plausible.js",
"types": "./plausible.d.ts"
}

69
tracker/npm_package/plausible.d.ts vendored Normal file
View File

@ -0,0 +1,69 @@
// Sets up the tracking library. Can be called once.
export function init(config: PlausibleConfig): void
// Tracks an event, requires `init` to be called first.
export function track(eventName: string, options: PlausibleEventOptions): void
export interface PlausibleConfig {
// Domain of the site to track. Should be registered with Plausible.
domain: string,
// The URL of the Plausible API endpoint. Defaults to https://plausible.io/api/event
// See proxying guide at https://plausible.io/docs/proxy/introduction
endpoint?: string
// Whether to automatically capture pageviews. Defaults to true.
autoCapturePageviews?: boolean
// Whether the page uses hash based routing. Defaults to false.
// Read more at https://plausible.io/docs/hash-based-routing
hashBasedRouting?: boolean
// Whether to track outbound link clicks. Defaults to false.
outboundLinks?: boolean
// Whether to track file downloads. Defaults to false.
fileDownloads?: boolean
// Whether to track form submissions. Defaults to false.
formSubmissions?: boolean
// Whether to capture events on localhost. Defaults to false.
captureOnLocalhost?: boolean
// Custom properties to add to all events tracked.
// If passed as a function, it will be called when `track` is called.
customProperties?: CustomProperties | ((eventName: string) => CustomProperties)
}
export interface PlausibleEventOptions {
// Custom properties to add to the event.
// Read more at https://plausible.io/docs/custom-props/introduction
props?: CustomProperties
// Whether the tracked event is interactive. Defaults to true.
// By marking a custom event as non-interactive, it will not be counted towards bounce rate calculations.
interactive?: boolean
// Revenue data to add to the event.
// Read more at https://plausible.io/docs/ecommerce-revenue-tracking
revenue?: PlausibleEventRevenue
// Called when request to `endpoint` completes or is ignored.
// When request is ignored, the result will be undefined.
// When request was delivered, the result will be an object with the response status code of the request.
callback?: (result?: { status: number } | undefined) => void
// Overrides the URL of the page that the event is being tracked on.
// If not provided, `location.href` will be used.
u?: string
}
export type CustomProperties = Record<string, string>
export type PlausibleEventRevenue = {
// Revenue amount in `currency`
amount: number | string,
// Currency is an ISO 4217 string representing the currency code, e.g. "USD" or "EUR"
currency: string
}

View File

@ -1,5 +1,5 @@
{
"tracker_script_version": 14,
"tracker_script_version": 15,
"type": "module",
"scripts": {
"deploy": "node compile.js",

View File

@ -3,7 +3,7 @@ var document = window.document
if (COMPILE_COMPAT) {
var scriptEl = document.getElementById('plausible')
} else {
} else if (COMPILE_PLAUSIBLE_LEGACY_VARIANT) {
var scriptEl = document.currentScript
}
@ -21,7 +21,7 @@ function defaultEndpoint() {
}
export function init(overrides) {
if (COMPILE_CONFIG) {
if (COMPILE_PLAUSIBLE_WEB) {
// This will be dynamically replaced by a config json object in the script serving endpoint
config = "<%= @config_js %>"
Object.assign(config, overrides, {
@ -30,7 +30,22 @@ export function init(overrides) {
// autoCapturePageviews defaults to `true`
autoCapturePageviews: overrides.autoCapturePageviews !== false
})
} else if (COMPILE_PLAUSIBLE_NPM) {
if (config.isInitialized) {
throw new Error('plausible.init() can only be called once')
}
if (!overrides || !overrides.domain) {
throw new Error('plausible.init(): domain argument is required')
}
if (!overrides.endpoint) {
overrides.endpoint = 'https://plausible.io/api/event'
}
Object.assign(config, overrides, {
autoCapturePageviews: overrides.autoCapturePageviews !== false
})
config.isInitialized = true
} else {
// Legacy variant
config.endpoint = scriptEl.getAttribute('data-api') || defaultEndpoint()
config.domain = scriptEl.getAttribute('data-domain')
}

View File

@ -5,7 +5,7 @@ import { init as initAutocapture } from './autocapture'
import { track } from './track'
function init(overrides) {
if (COMPILE_CONFIG && window.plausible && window.plausible.l) {
if (COMPILE_PLAUSIBLE_WEB && window.plausible && window.plausible.l) {
console.warn('Plausible analytics script was already initialized, skipping init')
return
}
@ -17,22 +17,25 @@ function init(overrides) {
initAutocapture(track)
}
if (COMPILE_OUTBOUND_LINKS || COMPILE_FILE_DOWNLOADS || COMPILE_TAGGED_EVENTS || COMPILE_CONFIG) {
if (COMPILE_PLAUSIBLE_WEB || COMPILE_PLAUSIBLE_NPM || COMPILE_OUTBOUND_LINKS || COMPILE_FILE_DOWNLOADS || COMPILE_TAGGED_EVENTS) {
initCustomEvents()
}
// Call `track` for any events that were queued via plausible('event') before `init` was called
var queue = (window.plausible && window.plausible.q) || []
for (var i = 0; i < queue.length; i++) {
track.apply(this, queue[i])
}
window.plausible = track
window.plausible.init = init
window.plausible.l = true
if (COMPILE_PLAUSIBLE_WEB || COMPILE_PLAUSIBLE_LEGACY_VARIANT) {
// Call `track` for any events that were queued via plausible('event') before `init` was called
var queue = (window.plausible && window.plausible.q) || []
for (var i = 0; i < queue.length; i++) {
track.apply(this, queue[i])
}
window.plausible = track
window.plausible.init = init
window.plausible.l = true
}
}
if (COMPILE_CONFIG) {
if (COMPILE_PLAUSIBLE_WEB) {
window.plausible = (window.plausible || {})
if (plausible.o) {
@ -40,6 +43,10 @@ if (COMPILE_CONFIG) {
}
plausible.init = init
} else {
} else if (COMPILE_PLAUSIBLE_LEGACY_VARIANT) {
// Legacy variants automatically initialize based compile variables
init()
}
// In npm module, we export the init and track functions
// export { init, track }

View File

@ -3,6 +3,10 @@ import { prePageviewTrack, postPageviewTrack, onPageviewIgnored } from './engage
import { config, scriptEl, location, document } from './config'
export function track(eventName, options) {
if (COMPILE_PLAUSIBLE_NPM && !config.isInitialized) {
throw new Error('plausible.track() can only be called after plausible.init()')
}
var isPageview = eventName === 'pageview'
if (isPageview) {

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Plausible NPM package tests</title>
<script type="module">
import { init, track } from './tracker/js/npm_package/plausible.js'
window.init = init
window.track = track
</script>
</head>
<body>
<a id="file-download" href="https://awesome.website.com/file.pdf">Download</a>
<a id="outbound-link" href="https://example.com">Outbound link</a>
<a id="manual-pageview" onclick="window.track('pageview', { u: '/:test-plausible' })">Manual pageview</a>
<a id="custom-event" onclick="window.track('Custom event', { props: { author: 'Karl' } })">Custom event</a>
<button id="tagged-event" class="plausible-event-name=Purchase plausible-event-foo=bar plausible-revenue-currency=EUR plausible-revenue-amount=13.32">
Tagged event
</button>
</body>
</html>

View File

@ -14,7 +14,7 @@
<a id="outbound-link" href="https://example.com">Outbound link</a>
<a id="manual-pageview" onclick="plausible('pageview', { u: '/:test-plausible-web' })">Manual pageview</a>
<a id="manual-pageview" onclick="plausible('pageview', { u: '/:test-plausible' })">Manual pageview</a>
<a id="custom-event" onclick="plausible('Custom event', { props: { author: 'Karl' } })">Custom event</a>

View File

@ -2,7 +2,8 @@ import { test, Page } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
import {
expectPlausibleInAction,
ignoreEngagementRequests
isEngagementEvent,
isPageviewEvent
} from './support/test-utils'
import { initializePageDynamically } from './support/initialize-page-dynamically'
import { ScriptConfig } from './support/types'
@ -33,7 +34,7 @@ test('does not track form submissions when the feature is disabled', async ({
await page.goto(url)
await page.click('input[type="submit"]')
},
shouldIgnoreRequest: ignoreEngagementRequests,
shouldIgnoreRequest: isEngagementEvent,
expectedRequests: [{ n: 'pageview' }],
refutedRequests: [
{
@ -66,7 +67,7 @@ test.describe('form submissions feature is enabled', () => {
await page.fill('input[type="text"]', 'Any Name')
await page.click('input[type="submit"]')
},
shouldIgnoreRequest: pageviewOrEngagementEvent,
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent],
expectedRequests: [
{
n: 'Form Submission',
@ -97,7 +98,7 @@ test.describe('form submissions feature is enabled', () => {
await ensurePlausibleInitialized(page)
await page.click('input[type="submit"]')
},
shouldIgnoreRequest: pageviewOrEngagementEvent,
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent],
expectedRequests: [
{
n: 'Form Submission',
@ -136,7 +137,7 @@ test.describe('form submissions feature is enabled', () => {
await page.click('button#dynamically-insert-form')
await page.click('input[type="submit"]')
},
shouldIgnoreRequest: pageviewOrEngagementEvent,
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent],
expectedRequests: [
{
n: 'Form Submission',
@ -170,7 +171,7 @@ test.describe('form submissions feature is enabled', () => {
await page.fill('input[type="email"]', 'invalid email')
await page.click('input[type="submit"]')
},
shouldIgnoreRequest: pageviewOrEngagementEvent,
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent],
expectedRequests: [
{
n: 'Form Submission',
@ -203,7 +204,7 @@ test.describe('form submissions feature is enabled', () => {
await page.fill('input[type="email"]', 'invalid email')
await page.click('input[type="submit"]')
},
shouldIgnoreRequest: ignoreEngagementRequests,
shouldIgnoreRequest: isEngagementEvent,
expectedRequests: [{ n: 'pageview' }],
refutedRequests: [
{
@ -236,7 +237,7 @@ test.describe('form submissions feature is enabled', () => {
await page.click('button#trigger-FormElement-submit')
},
shouldIgnoreRequest: ignoreEngagementRequests,
shouldIgnoreRequest: isEngagementEvent,
expectedRequests: [{ n: 'pageview' }],
refutedRequests: [
{
@ -272,7 +273,7 @@ test.describe('form submissions feature is enabled', () => {
await ensurePlausibleInitialized(page)
await page.click('input[type="submit"]')
},
shouldIgnoreRequest: pageviewOrEngagementEvent,
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent],
expectedRequests: [
{
n: 'Form Submission',
@ -286,7 +287,7 @@ test.describe('form submissions feature is enabled', () => {
await page.fill('input[type="email"]', 'customer@example.com')
await page.keyboard.press('Enter')
},
shouldIgnoreRequest: pageviewOrEngagementEvent,
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent],
expectedRequests: [
{
n: 'Form Submission',
@ -305,9 +306,6 @@ function ensurePlausibleInitialized(page: Page) {
return page.waitForFunction(() => (window as any).plausible?.l === true)
}
const pageviewOrEngagementEvent = ({ n }) =>
['pageview', 'engagement'].includes(n)
/**
* This is a stub for custom form onsubmit handlers Plausible users may have on their websites.
* Overriding onsubmit with a custom handler is common practice in web development for a variety of reasons (mostly UX),

View File

@ -0,0 +1,71 @@
/*
Tests for plausible-npm.js variant
Config is set at init(), as we expect consumers to do in production.
*/
import {
expectPlausibleInAction,
hideAndShowCurrentTab,
metaKey,
mockRequest,
e as expecting
} from './support/test-utils'
import { test, expect } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
import { testPlausibleConfiguration, callInit } from './shared-configuration-tests'
const DEFAULT_CONFIG = {
domain: 'example.com',
endpoint: `${LOCAL_SERVER_ADDR}/api/event`,
captureOnLocalhost: true
}
async function openPage(page, config, options = {}) {
await page.goto("/plausible-npm.html")
await page.waitForFunction('window.init !== undefined')
if (!options.skipPlausibleInit) {
await callInit(page, { ...DEFAULT_CONFIG, ...config }, 'window')
}
}
test.describe('NPM package', () => {
testPlausibleConfiguration({
openPage,
initPlausible: (page, config) => callInit(page, { ...DEFAULT_CONFIG, ...config }, 'window'),
fixtureName: 'plausible-npm.html',
fixtureTitle: 'Plausible NPM package tests'
})
test('does not support calling `init` without `domain`', async ({ page }) => {
await openPage(page, {}, { skipPlausibleInit: true })
await expect(async () => {
await callInit(page, { hashBasedRouting: true }, 'window')
}).rejects.toThrow("plausible.init(): domain argument is required")
})
test('does not support calling `init` with no configuration', async ({ page }) => {
await openPage(page, {}, { skipPlausibleInit: true })
await expect(async () => {
await callInit(page, undefined, 'window')
}).rejects.toThrow("plausible.init(): domain argument is required")
})
test('track throws if called before init', async ({ page }) => {
await openPage(page, {}, { skipPlausibleInit: true })
await expect(async () => {
await page.evaluate(() => {
window.track('pageview')
})
}).rejects.toThrow("plausible.track() can only be called after plausible.init()")
})
test('does not support calling `init` twice', async ({ page }) => {
await openPage(page, {}, { skipPlausibleInit: true })
await callInit(page, DEFAULT_CONFIG, 'window')
await expect(async () => {
await callInit(page, DEFAULT_CONFIG, 'window')
}).rejects.toThrow("plausible.init() can only be called once")
})
})

View File

@ -7,14 +7,11 @@ better test the script in isolation of the plausible codebase.
import {
expectPlausibleInAction,
hideAndShowCurrentTab,
metaKey,
mockRequest,
e as expecting,
ignoreEngagementRequests
e as expecting
} from './support/test-utils'
import { test, expect } from '@playwright/test'
import { LOCAL_SERVER_ADDR } from './support/server'
import { testPlausibleConfiguration, callInit } from './shared-configuration-tests'
const DEFAULT_CONFIG = {
domain: 'example.com',
@ -36,210 +33,11 @@ async function openPage(page, config, options = {}) {
}
test.describe('plausible-web.js', () => {
test.beforeEach(({ page }) => {
// Mock file download requests
mockRequest(page, 'https://awesome.website.com/file.pdf')
})
test('triggers pageview and engagement automatically', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, {}),
expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining('plausible-web.html')}]
})
await expectPlausibleInAction(page, {
action: () => hideAndShowCurrentTab(page, {delay: 2000}),
expectedRequests: [{n: 'engagement', d: 'example.com', u: expecting.stringContaining('plausible-web.html')}],
})
})
test('does not trigger any events when `local` config is disabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, { captureOnLocalhost: false }),
expectedRequests: [],
refutedRequests: [{ n: 'pageview' }]
})
})
test('does not track pageview props without the feature being enabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {})
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }],
shouldIgnoreRequest: ignoreEngagementRequests
})
})
test('does not track outbound links without the feature being enabled', async ({ page, browserName }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {})
await page.click('#outbound-link')
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }],
refutedRequests: [{ n: 'Outbound Link: Click' }],
shouldIgnoreRequest: ignoreEngagementRequests
})
})
test('does not track file downloads without feature being enabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {})
await page.click('#file-download', { modifiers: [metaKey()] })
},
expectedRequests: [{ n: 'pageview' } ],
refutedRequests: [{ n: 'File Download' }],
shouldIgnoreRequest: ignoreEngagementRequests
})
})
test('tracks outbound links (when feature enabled)', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, { outboundLinks: true })
await page.click('#outbound-link')
},
expectedRequests: [
{ n: 'pageview', d: 'example.com', u: expecting.stringContaining('plausible-web.html') },
{ n: 'Outbound Link: Click', d: 'example.com', u: expecting.stringContaining('plausible-web.html'), p: { url: 'https://example.com/' } },
]
})
})
test('tracks file downloads (when feature enabled)', async ({ page }) => {
await openPage(page, { fileDownloads: true })
await expectPlausibleInAction(page, {
action: () => page.click('#file-download'),
expectedRequests: [{ n: 'File Download', p: { url: 'https://awesome.website.com/file.pdf' } }],
shouldIgnoreRequest: ignoreEngagementRequests
})
})
test('tracks static custom pageview properties (when feature enabled)', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({ customProperties: { "some-prop": "456" } })
})
},
expectedRequests: [{ n: 'pageview', p: { "some-prop": "456" } }]
})
})
test('tracks dynamic custom pageview properties (when feature enabled)', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({ customProperties: () => ({ "title": document.title }) })
})
},
expectedRequests: [{ n: 'pageview', p: { "title": "plausible-web.js tests" } }]
})
})
test('tracks dynamic custom pageview properties with custom events and engagements', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({
customProperties: (eventName) => ({ "author": "Uku", "some-prop": "456", [eventName]: "1" })
})
})
await page.click('#custom-event')
await hideAndShowCurrentTab(page, { delay: 200 })
},
expectedRequests: [
{ n: 'pageview', p: { "author": "Uku", "some-prop": "456", "pageview": "1"} },
// Passed property to `plausible` call overrides the default from `config.customProperties`
{ n: 'Custom event', p: { "author": "Karl", "some-prop": "456", "Custom event": "1" } },
// Engagement event inherits props from the pageview event
{ n: 'engagement', p: { "author": "Uku", "some-prop": "456", "pageview": "1"} },
]
})
})
test('invalid function customProperties are ignored', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({ customProperties: () => document.title })
})
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }]
})
})
test('invalid customProperties are ignored', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({ customProperties: "abcdef" })
})
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }]
})
})
test('autoCapturePageviews=false mode does not track pageviews', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({ autoCapturePageviews: false })
})
await hideAndShowCurrentTab(page, { delay: 200 })
},
expectedRequests: [],
refutedRequests: [{ n: 'pageview' }, { n: 'engagement' }]
})
})
test('autoCapturePageviews=false mode after manual pageview continues tracking', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({ autoCapturePageviews: false })
})
await page.click('#manual-pageview')
await hideAndShowCurrentTab(page, { delay: 200 })
},
expectedRequests: [
{ n: 'pageview', u: '/:test-plausible-web', d: 'example.com' },
{ n: 'engagement', u: '/:test-plausible-web', d: 'example.com' },
],
})
})
test('does not send `h` parameter when `hashBasedRouting` config is disabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, {}),
expectedRequests: [{ n: 'pageview', h: expecting.toBeUndefined() }]
})
})
test('sends `h` parameter when `hash` config is enabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, { hashBasedRouting: true }),
expectedRequests: [{ n: 'pageview', h: 1 }]
})
})
test('tracking tagged events with revenue', async ({ page }) => {
await openPage(page, {})
await expectPlausibleInAction(page, {
action: () => page.click('#tagged-event'),
expectedRequests: [{ n: 'Purchase', p: { foo: 'bar' }, $: { currency: 'EUR', amount: '13.32' } }]
})
testPlausibleConfiguration({
openPage,
initPlausible: (page, config) => callInit(page, config, 'window.plausible'),
fixtureName: 'plausible-web.html',
fixtureTitle: 'plausible-web.js tests'
})
test('with queue code included, respects `plausible` calls made before the script is loaded', async ({ page }) => {
@ -291,28 +89,9 @@ test.describe('plausible-web.js', () => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({
domain: 'another-domain.com'
})
})
await callInit(page, { domain: 'another-domain.com' }, 'window.plausible')
},
expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining('plausible-web.html') }]
})
})
test('supports overriding the endpoint with a custom proxy via `init`', async ({ page }) => {
await expectPlausibleInAction(page, {
pathToMock: 'http://proxy.io/endpoint',
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await page.evaluate(() => {
plausible.init({
endpoint: 'http://proxy.io/endpoint'
})
})
},
expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining('plausible-web.html')}]
})
})
})

View File

@ -0,0 +1,236 @@
import {
expectPlausibleInAction,
hideAndShowCurrentTab,
metaKey,
mockRequest,
e as expecting,
isPageviewEvent,
isEngagementEvent
} from './support/test-utils'
import { test } from '@playwright/test'
// Wrapper around calling `plausible.init` in the page context for users of `testPlausibleConfiguration`
export async function callInit(page, config, parent) {
// Stringify the customProperties function to work around evaluate not being able to serialize functions
if (config && typeof config.customProperties === 'function') {
config.customProperties = { "_wrapFunction": config.customProperties.toString() }
}
await page.evaluate(({ config, parent }) => {
if (config && config.customProperties && config.customProperties._wrapFunction) {
config.customProperties = new Function(`return (${config.customProperties._wrapFunction})`)();
}
eval(parent).init(config)
}, { config, parent })
}
export function testPlausibleConfiguration({ openPage, initPlausible, fixtureName, fixtureTitle }) {
test.describe('shared configuration tests', () => {
test.beforeEach(({ page }) => {
// Mock file download requests
mockRequest(page, 'https://awesome.website.com/file.pdf')
})
test('triggers pageview and engagement automatically', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, {}),
expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining(fixtureName)}],
})
await expectPlausibleInAction(page, {
action: () => hideAndShowCurrentTab(page, {delay: 2000}),
expectedRequests: [{n: 'engagement', d: 'example.com', u: expecting.stringContaining(fixtureName)}],
})
})
test('does not trigger any events when `local` config is disabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, { captureOnLocalhost: false }),
expectedRequests: [],
refutedRequests: [{ n: 'pageview' }]
})
})
test('does not track pageview props without the feature being enabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {})
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }],
shouldIgnoreRequest: isEngagementEvent
})
})
test('does not track outbound links without the feature being enabled', async ({ page, browserName }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {})
await page.click('#outbound-link')
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }],
refutedRequests: [{ n: 'Outbound Link: Click' }],
shouldIgnoreRequest: isEngagementEvent
})
})
test('does not track file downloads without feature being enabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {})
await page.click('#file-download', { modifiers: [metaKey()] })
},
expectedRequests: [{ n: 'pageview' } ],
refutedRequests: [{ n: 'File Download' }],
shouldIgnoreRequest: isEngagementEvent
})
})
test('tracks outbound links (when feature enabled)', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, { outboundLinks: true })
await page.click('#outbound-link')
},
expectedRequests: [
{ n: 'pageview', d: 'example.com', u: expecting.stringContaining(fixtureName) },
{ n: 'Outbound Link: Click', d: 'example.com', u: expecting.stringContaining(fixtureName), p: { url: 'https://example.com/' } }
],
shouldIgnoreRequest: isEngagementEvent
})
})
test('tracks file downloads (when feature enabled)', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, { fileDownloads: true })
await page.click('#file-download')
},
expectedRequests: [{ n: 'File Download', p: { url: 'https://awesome.website.com/file.pdf' } }],
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent]
})
})
test('tracks static custom pageview properties (when feature enabled)', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, { customProperties: { "some-prop": "456" } })
},
expectedRequests: [{ n: 'pageview', p: { "some-prop": "456" } }]
})
})
test('tracks dynamic custom pageview properties (when feature enabled)', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, { customProperties: () => ({ "title": document.title }) })
},
expectedRequests: [{ n: 'pageview', p: { "title": fixtureTitle } }]
})
})
test('tracks dynamic custom pageview properties with custom events and engagements', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, {
customProperties: (eventName) => ({ "author": "Uku", "some-prop": "456", [eventName]: "1" })
})
await page.click('#custom-event')
await hideAndShowCurrentTab(page, { delay: 200 })
},
expectedRequests: [
{ n: 'pageview', p: { "author": "Uku", "some-prop": "456", "pageview": "1"} },
// Passed property to `plausible` call overrides the default from `config.customProperties`
{ n: 'Custom event', p: { "author": "Karl", "some-prop": "456", "Custom event": "1" } },
// Engagement event inherits props from the pageview event
{ n: 'engagement', p: { "author": "Uku", "some-prop": "456", "pageview": "1"} },
]
})
})
test('invalid function customProperties are ignored', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, { customProperties: () => document.title })
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }]
})
})
test('invalid customProperties are ignored', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, { customProperties: "abcdef" })
},
expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }]
})
})
test('autoCapturePageviews=false mode does not track pageviews', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, { autoCapturePageviews: false })
await hideAndShowCurrentTab(page, { delay: 200 })
},
expectedRequests: [],
refutedRequests: [{ n: 'pageview' }, { n: 'engagement' }]
})
})
test('autoCapturePageviews=false mode after manual pageview continues tracking', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, { autoCapturePageviews: false })
await page.click('#manual-pageview')
await hideAndShowCurrentTab(page, { delay: 200 })
},
expectedRequests: [
{ n: 'pageview', u: '/:test-plausible', d: 'example.com' },
{ n: 'engagement', u: '/:test-plausible', d: 'example.com' },
],
})
})
test('does not send `h` parameter when `hashBasedRouting` config is disabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, {}),
expectedRequests: [{ n: 'pageview', h: expecting.toBeUndefined() }]
})
})
test('sends `h` parameter when `hash` config is enabled', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => openPage(page, { hashBasedRouting: true }),
expectedRequests: [{ n: 'pageview', h: 1 }]
})
})
test('tracking tagged events with revenue', async ({ page }) => {
await expectPlausibleInAction(page, {
action: async () => {
await openPage(page, {})
await page.click('#tagged-event')
},
expectedRequests: [{ n: 'Purchase', p: { foo: 'bar' }, $: { currency: 'EUR', amount: '13.32' } }],
shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent]
})
})
test('supports overriding the endpoint with a custom proxy via `init`', async ({ page }) => {
await expectPlausibleInAction(page, {
pathToMock: 'http://proxy.io/endpoint',
action: async () => {
await openPage(page, {}, { skipPlausibleInit: true })
await initPlausible(page, { endpoint: 'http://proxy.io/endpoint' })
},
expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining(fixtureName)}]
})
})
})
}

View File

@ -1,5 +1,8 @@
import { Page } from '@playwright/test'
type RequestData = Record<string, unknown>
type ShouldIgnoreRequest = (requestData?: RequestData) => boolean
export async function mockManyRequests({
page,
path,
@ -12,13 +15,13 @@ export async function mockManyRequests({
path: string
numberOfRequests: number
responseDelay?: number
shouldIgnoreRequest?: (requestData?: Record<string, unknown>) => boolean
shouldIgnoreRequest?: ShouldIgnoreRequest | ShouldIgnoreRequest[]
mockRequestTimeout?: number
}) {
const requestList: any[] = []
await page.route(path, async (route, request) => {
const postData = request.postDataJSON()
if (!shouldIgnoreRequest || !shouldIgnoreRequest(postData)) {
if (shouldAllow(postData, shouldIgnoreRequest)) {
requestList.push(postData)
}
if (responseDelay) {
@ -51,6 +54,16 @@ export async function mockManyRequests({
return getWaitForRequests
}
function shouldAllow(requestData: RequestData, ignores: ShouldIgnoreRequest | ShouldIgnoreRequest[] | undefined) {
if (Array.isArray(ignores)) {
return !ignores.some((shouldIgnore) => shouldIgnore(requestData))
} else if (ignores) {
return !ignores(requestData)
} else {
return true
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@ -17,8 +17,8 @@ export const LOCAL_SERVER_ADDR = `http://localhost:${LOCAL_SERVER_PORT}`
export function runLocalFileServer() {
app.use(express.static(FIXTURES_PATH));
app.get('/tracker/js/:name', async (req, res) => {
const name = req.params.name
app.get('/tracker/js/*', async (req, res) => {
const name = req.params[0]
const variant = VARIANTS.find((variant) => variant.name === name)
let code = await compileFile(variant, { returnCode: true })

View File

@ -49,7 +49,7 @@ export const metaKey = function() {
* is `expectedRequests.length + refutedRequests.length`.
* @param {number} [args.expectedRequestCount] - When provided, expects the total amount of
* event requests made to match this number.
* @param {Function} [args.shouldIgnoreRequest] - When provided, ignores certain requests
* @param {Array|Function} [args.shouldIgnoreRequest] - When provided, ignores certain requests
* @param {number} [args.responseDelay] - When provided, delays the response from the Plausible
* API by the given number of milliseconds.
* @param {Function} [args.mockRequestTimeout] - How long to wait for the requests to be made
@ -105,17 +105,18 @@ export const expectPlausibleInAction = async function (page, {
const refutedBodySubsetsErrorMessage = `The following requests were made, but were not expected:\n\n${JSON.stringify(refutedButFoundRequestBodies, null, 4)}`
expect(refutedButFoundRequestBodies, refutedBodySubsetsErrorMessage).toHaveLength(0)
expect(requestBodies.length).toBe(requestsToExpect)
const unexpectedRequestBodiesErrorMessage = `Expected ${requestsToExpect} requests, but received ${requestBodies.length}:\n\n${JSON.stringify(requestBodies, null, 4)}`
expect(requestBodies.length, unexpectedRequestBodiesErrorMessage).toBe(requestsToExpect)
return requestBodies
}
export const ignoreEngagementRequests = function(requestPostData) {
return requestPostData.n === 'engagement'
export const isPageviewEvent = function(requestPostData) {
return requestPostData.n === 'pageview'
}
export const ignorePageleaveRequests = function(requestPostData) {
return requestPostData.n === 'pageleave'
export const isEngagementEvent = function(requestPostData) {
return requestPostData.n === 'engagement'
}
async function toggleTabVisibility(page, hide) {